From 85c6e4a81ce783c61dcf755f7334e9f54997c84e Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 5 Aug 2026 10:30:29 +0530 Subject: [PATCH 01/21] WIP: Initial sketch of the parquet writer design --- dataframe-parquet/dataframe-parquet.cabal | 1 + .../src/DataFrame/IO/Parquet/Writer.hs | 144 ++++++++++++++++++ dataframe.cabal | 1 + examples/examples.cabal | 1 + 4 files changed, 147 insertions(+) create mode 100644 dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs diff --git a/dataframe-parquet/dataframe-parquet.cabal b/dataframe-parquet/dataframe-parquet.cabal index f1bfc61e..1a377b4c 100644 --- a/dataframe-parquet/dataframe-parquet.cabal +++ b/dataframe-parquet/dataframe-parquet.cabal @@ -45,6 +45,7 @@ library DataFrame.IO.Parquet.Thrift DataFrame.IO.Parquet.Time DataFrame.IO.Parquet.Utils + DataFrame.IO.Parquet.Writer DataFrame.IO.Utils.RandomAccess DataFrame.Typed.IO.Parquet build-depends: base >= 4 && < 5, diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs new file mode 100644 index 00000000..509da854 --- /dev/null +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs @@ -0,0 +1,144 @@ +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE LambdaCase #-} + +module DataFrame.IO.Parquet.Writer (writeParquet, writeParquetWithOptions, defaultParquetWriteOptions) where + +import DataFrame.Core (DataFrame) + +data ParquetWriteOptions = ParquetWriteOptions + { rowGroupSize :: Int64 + } + deriving (Eq, Show) + +defaultParquetWriteOptions :: ParquetWriteOptions +defaultParquetWriteOptions = undefined + +writeParquet :: FilePath -> DataFrame -> IO () +writeParquet = writeParquetWithOptions defaultParquetWriteOptions + +writeParquetWithOptions :: ParquetWriteOptions -> FilePath -> DataFrame -> IO () +writeParquetWithOptions options filepath dataframe = do + let + initialState = WriterStateRecord 0 options.rowGroupSize emptyColumnChunkState + (_, (pages, metadata)) = runState initialState + $ foldChunks (chunkDataFrame options.rowGroupSize dataframe) generateRowGroup + schema = generateSchema dataframe + writeFile filepath (pages <> buildMetadata metadata) + +-- I don't see how this scales to multiple reader/writer threads so we may have +-- to change this later + +data WriterStateRecord = WriterStateRecord + { offset :: Int64 + , chunkSize :: Int64 + , columnChunkState :: ColumnChunkStateRecord + } + +data ColumnChunkStateRecord = ColumnChunkStateRecord + { thriftType :: ThriftType + , encodings :: Set Encoding + , codec :: CompressionCodec + , total_uncompressed_size :: Int64 + , total_compressed_size :: Int64 + , data_page_offset :: Maybe Int64 + , dictionary_page_offset :: Maybe Int64 + } +initColumnChunkState :: Column -> ColumnChunkStateRecord +initColumnChunkState = undefined + +emptyColumnChunkState :: ColumnChunkStateRecord +emptyColumnChunkState = undefined + +type WriterState a = State WriterStateRecord a + +generateRowGroup :: DataFrame -> WriterState (Builder, RowGroup) +generateRowGroup dataframe = do + (builder, columns) <- foldChunks columns generateColumnChunk + --TODO big memory if big columns. Use vectors instead + let total_byte_size = sum . map (total_uncompressed_size . cc_meta_data) $ columns + num_rows = fst . dataframeDimensions $ dataframe + total_uncompressed_size = Just $ sum. map (total_uncompressed_size . cc_meta_data) $ columns + rowGroup = undefined --TODO + return (builder, rowGroup) + + +generateColumnChunk :: Column -> WriterState (Builder, ColumnChunk) +generateColumnChunk column = do + writerState <- get + put writerState{columnChunkState = initColumnChunkState column} + (builder, _) <- foldChunks (chunkColumn writerState.chunkSize column) generatePage + let file_path = Nothing + file_offset = 0 + meta_data = undefined + offset_index_offset = Nothing + offset_index_length = Nothing + column_index_offset = Nothing + column_index_length = Nothing + crypto_metadata = Nothing + encrypted_column_metadata = Nothing + columnChunk = undefined -- TODO + return (builder, columnChunk) + + +generatePage :: Column -> WriterState (Builder, PageHeader) -- PageHeader is also already in the builder +generatePage = undefined + +generateSchema :: DataFrame -> [SchemaElement] +generateSchema = undefined + +data Metadata = Metadata + { schema :: [SchemaElement] + , rowGroups :: [RowGroup] + } + +buildMetadata :: Metadata -> Builder +buildMetadata = undefined + +chunkDataFrame :: Int -> DataFrame -> [DataFrame] +chunkDataFrame = undefined + +chunkColumn :: Int -> Column -> [Column] +chunkColumn = undefined + +-- TODO IF it becomes a problem, allocate an array ahead of time for storing the metadata +foldChunks :: [chunk] -> (chunk -> WriterState (Builder, metadata)) -> WriterState (Builder, Sequence metadata) +foldChunks chunks process = foldl' f (mempty, mempty) chunks + where + f (builder, metadata) chunk = let (nextBuilder, nextMetadata) = process chunk + in (builder <> nextBuilder, metadata <> nextMetadata) + +second :: (b -> c) -> (a, b) -> (a, c) +second f (a, b) = (a, f b) + +newtype State s a = State { runState :: s -> (s, a) } + +class Monad m => MonadState s m where + get :: m s + get = state $ \s -> (s, s) + put :: s -> m () + put s = state $ \_ -> (s, ()) + state :: (s -> (s, a)) -> m a + +instance Functor (State s) where + fmap f (State run) = State $ second f . run + +instance Applicative (State s) where + pure a = State $ (,a) + (State r1) <*> (State r2) = State (\s -> + let (s', f) = r1 s + (s'', x) = r2 s' + in (s'', f x) + ) + +instance Monad (State s) where + return = pure + (State run) >>= f = State $ \s -> + let (s', a) = run s + in runState (f a) s' + +instance MonadState s (State s) where + state = State + + +--TODO ColumnIndices and OffsetIndices +-- TODO ColumnOrders diff --git a/dataframe.cabal b/dataframe.cabal index d5cf40d5..b4a12106 100644 --- a/dataframe.cabal +++ b/dataframe.cabal @@ -153,6 +153,7 @@ library DataFrame.IO.Parquet.Page, DataFrame.IO.Parquet.Schema, DataFrame.IO.Parquet.Utils, + DataFrame.IO.Parquet.Writer, DataFrame.IO.Parquet.Seeking, DataFrame.IO.Parquet.Time, DataFrame.IO.Utils.RandomAccess, diff --git a/examples/examples.cabal b/examples/examples.cabal index f4531c9b..05de1429 100644 --- a/examples/examples.cabal +++ b/examples/examples.cabal @@ -72,6 +72,7 @@ executable examples DataFrame.IO.Parquet.Thrift, DataFrame.IO.Parquet.Time, DataFrame.IO.Parquet.Utils, + DataFrame.IO.Parquet.Writer, DataFrame.IO.Utils.RandomAccess, DataFrame.Lazy.IO.CSV, DataFrame.Lazy.IO.Binary, From 89d7b6d624c40e4aba49b33b5700d3935a633f99 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Tue, 18 Aug 2026 23:35:32 +0530 Subject: [PATCH 02/21] WIP Parquet Writer (does not compile) --- .../src/DataFrame/IO/Parquet/Writer.hs | 253 +++++++++++------- .../src/DataFrame/IO/Utils/RandomAccess.hs | 107 +++++++- 2 files changed, 265 insertions(+), 95 deletions(-) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs index 509da854..00b30dae 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs @@ -3,16 +3,114 @@ module DataFrame.IO.Parquet.Writer (writeParquet, writeParquetWithOptions, defaultParquetWriteOptions) where -import DataFrame.Core (DataFrame) +import qualified Data.Vector as Vector +import Data.Vector (Vector) + +--A Parquet file is a series of row groups followed by the file metadata (which contains the schema and the +-- metadata for all the rowgroups, which, in turn, contain the metadata for each column chunk). Inside each +-- rowgroup is a series of column chunks. Column chunks consist of a series of pages. Pages are the PageHeader +-- followed by RLE encoded definition levels (if they exist), RLE encoded repetition levels (if they exist), +-- and finally the encoded and then compressed data. I forgot about magic bytes. Those are there too. +-- +-- For a parquet file to acheive efficient compression we tend to desirc row groupts of a specific size and +-- for each of our column chunks to have pages that are of a specific size. So we must expose these fiddle +-- factors to the user so they can tune the writer to have the behavior they want. (there are subtleties to +-- this that are discussed further below) +-- data ParquetWriteOptions = ParquetWriteOptions - { rowGroupSize :: Int64 + { pageSize :: Int + , rowGroupSize :: Int + , batchSize :: Int + , preferredCompressionCodec :: CompressionCodec + , rowGroupBuffer :: RowGroupBuffer + , } deriving (Eq, Show) defaultParquetWriteOptions :: ParquetWriteOptions defaultParquetWriteOptions = undefined +-- We'll set the default Page size to 1 MiB and the default rowGroupSize to 128MiB (of course users will be +-- able to adjust these numbers through write options). We need to hold the entire RowGroup in memory as +-- we build is as the ColumnChunks need to be contiguous when written to disk. So we need to hold +-- buffers for each individual columnChunk as we go row by row and build them; the columnChunks cannot be +-- interleaved. + +-- Since dataframe is columnar to begin with, we could, in theory, go golumn by column by estimating the size +-- of a certain slice of a column, but I don't yet see a good way of doing this given we must run the gamut +-- of encodings and compressions applied to each of those ColumnChunks (and those compression libraries have +-- their own multifarious strategies for various kinds of data) +-- +-- Each row group has to be a certain size, but each column in a row group must contain the same number +-- of rows, even though each column may very well fit the same number of rows in very different amounts +-- of space. So how do we ensure that we both hit our page size target, our record size target, and have +-- the same number of rows in each column? +-- +-- First we must consider the page size and row group sizes to be best effort. They could be slightly above +-- or below the target. The characteristics of the parquet file will depend on both the write options and +-- the specific data being encoded. Second, we should run batches of rows through the writer, flushing when +-- we see that a page has met or exceeded its limit, and when a row group has done the same. So a row group +-- is flushed specifically only on batch boundaries and we get the same number of rows in every row group +-- except the last which will be smaller than the rest. +-- +-- But we should also not overshoot page size egregiously if the user sets a large batch size, so we can +-- batch the batches (sub-batch) and make it configurable so that page sizes can be tuned if needed. Note: +-- arrow-rs had something similar but they ran into issues where some columns had really large values. +-- See https://github.com/apache/arrow-rs/issues/10061. We may need to implement this eventually, but +-- I'm too lazy to do it right now. +-- +-- If larger row groups are required (up to a gigabyte in size if not more), we should provide users who +-- need to minimize memory usage an alternate two pass strategy where we first write to temporary files (one +-- per columnChunk) until the temporary files have grown to the size of what a rowgroup should actually be +-- and pipe the temporary files into the output. Essentially our rowgroup buffer is on disk instead of in +-- memory. This is slower but should use less memory. In cases where there is extra RAM available but the +-- user chooses the two pass strategy anyway, the temp files will tend to be held in the OS Page Cache (RAM) +-- anyway. +-- +-- refer to DataFrame.IO.Utils.RandomAccess for the buffer implementation. + +-- I tested write speeds by doing (on Apple Silicon) +-- `dd if=/dev/zero of=test bs={$n}k oflag=direct conv=fdatasync +-- Results: +-- +-- ``` +-- | block size | data (GiB) | time (s) | GiB/s | +-- |------------|------------|-----------|-------| +-- | 4k | 4.00 | 2.371 | 1.69 | +-- | 8k | 4.00 | 1.486 | 2.69 | +-- | 16k | 4.00 | 1.045 | 3.83 | +-- | 32k | 4.00 | 0.740 | 5.40 | +-- | 64k | 4.00 | 0.675 | 5.92 | +-- | 128k | 4.00 | 0.669 | 5.98 | +-- | 256k | 4.00 | 0.664 | 6.03 | +-- | 512k | 4.00 | 0.670 | 5.97 | +-- | 1024k | 4.00 | 0.664 | 6.02 | +-- | 4096k | 4.00 | 0.668 | 5.99 | +-- ``` +-- We see that our raw data-writing throughput caps out at 64k and declines slightly above that. So we +-- need to split our buffer into 64k chunks and flush each to dis k(of course, this +-- may very well vary from machine to machine, regardless, 64KiB is probably good enough). +-- + + +-- We need writers for each level of the Parquet file + +-- A RowGroupWriter that flushes into the file +data RowGroupEnv = RowGroupEnv + { rowGroupBuffer :: !(Vector ColumnChunkWriter) + , pageWriter :: PageWriter + } + +type RowGroupWriter b a = ReaderIO (RowGroupEnv b) a + +-- The RowGroupWriter has in its env a Vector of ColumnChunkWriters +-- (which is essentially the row group buffer) +type ColumnChunkWriter = ReaderIO + +-- And the RowGroupWriter also needs a PageWriter +type PageWriter = ReaderIO + writeParquet :: FilePath -> DataFrame -> IO () writeParquet = writeParquetWithOptions defaultParquetWriteOptions @@ -49,96 +147,63 @@ initColumnChunkState = undefined emptyColumnChunkState :: ColumnChunkStateRecord emptyColumnChunkState = undefined -type WriterState a = State WriterStateRecord a - -generateRowGroup :: DataFrame -> WriterState (Builder, RowGroup) -generateRowGroup dataframe = do - (builder, columns) <- foldChunks columns generateColumnChunk - --TODO big memory if big columns. Use vectors instead - let total_byte_size = sum . map (total_uncompressed_size . cc_meta_data) $ columns - num_rows = fst . dataframeDimensions $ dataframe - total_uncompressed_size = Just $ sum. map (total_uncompressed_size . cc_meta_data) $ columns - rowGroup = undefined --TODO - return (builder, rowGroup) - - -generateColumnChunk :: Column -> WriterState (Builder, ColumnChunk) -generateColumnChunk column = do - writerState <- get - put writerState{columnChunkState = initColumnChunkState column} - (builder, _) <- foldChunks (chunkColumn writerState.chunkSize column) generatePage - let file_path = Nothing - file_offset = 0 - meta_data = undefined - offset_index_offset = Nothing - offset_index_length = Nothing - column_index_offset = Nothing - column_index_length = Nothing - crypto_metadata = Nothing - encrypted_column_metadata = Nothing - columnChunk = undefined -- TODO - return (builder, columnChunk) - - -generatePage :: Column -> WriterState (Builder, PageHeader) -- PageHeader is also already in the builder -generatePage = undefined - -generateSchema :: DataFrame -> [SchemaElement] -generateSchema = undefined - -data Metadata = Metadata - { schema :: [SchemaElement] - , rowGroups :: [RowGroup] - } - -buildMetadata :: Metadata -> Builder -buildMetadata = undefined - -chunkDataFrame :: Int -> DataFrame -> [DataFrame] -chunkDataFrame = undefined - -chunkColumn :: Int -> Column -> [Column] -chunkColumn = undefined - --- TODO IF it becomes a problem, allocate an array ahead of time for storing the metadata -foldChunks :: [chunk] -> (chunk -> WriterState (Builder, metadata)) -> WriterState (Builder, Sequence metadata) -foldChunks chunks process = foldl' f (mempty, mempty) chunks - where - f (builder, metadata) chunk = let (nextBuilder, nextMetadata) = process chunk - in (builder <> nextBuilder, metadata <> nextMetadata) - -second :: (b -> c) -> (a, b) -> (a, c) -second f (a, b) = (a, f b) - -newtype State s a = State { runState :: s -> (s, a) } - -class Monad m => MonadState s m where - get :: m s - get = state $ \s -> (s, s) - put :: s -> m () - put s = state $ \_ -> (s, ()) - state :: (s -> (s, a)) -> m a - -instance Functor (State s) where - fmap f (State run) = State $ second f . run - -instance Applicative (State s) where - pure a = State $ (,a) - (State r1) <*> (State r2) = State (\s -> - let (s', f) = r1 s - (s'', x) = r2 s' - in (s'', f x) - ) - -instance Monad (State s) where - return = pure - (State run) >>= f = State $ \s -> - let (s', a) = run s - in runState (f a) s' - -instance MonadState s (State s) where - state = State - +-- type WriterState a = State WriterStateRecord a +-- +-- generateRowGroup :: DataFrame -> WriterState (Builder, RowGroup) +-- generateRowGroup dataframe = do +-- (builder, columns) <- foldChunks columns generateColumnChunk +-- --TODO big memory if big columns. Use vectors instead +-- let total_byte_size = sum . map (total_uncompressed_size . cc_meta_data) $ columns +-- num_rows = fst . dataframeDimensions $ dataframe +-- total_uncompressed_size = Just $ sum. map (total_uncompressed_size . cc_meta_data) $ columns +-- rowGroup = undefined --TODO +-- return (builder, rowGroup) +-- +-- +-- generateColumnChunk :: Column -> WriterState (Builder, ColumnChunk) +-- generateColumnChunk column = do +-- writerState <- get +-- put writerState{columnChunkState = initColumnChunkState column} +-- (builder, _) <- foldChunks (chunkColumn writerState.chunkSize column) generatePage +-- let file_path = Nothing +-- file_offset = 0 +-- meta_data = undefined +-- offset_index_offset = Nothing +-- offset_index_length = Nothing +-- column_index_offset = Nothing +-- column_index_length = Nothing +-- crypto_metadata = Nothing +-- encrypted_column_metadata = Nothing +-- columnChunk = undefined -- TODO +-- return (builder, columnChunk) +-- +-- +-- generatePage :: Column -> WriterState (Builder, PageHeader) -- PageHeader is also already in the builder +-- generatePage = undefined +-- +-- generateSchema :: DataFrame -> [SchemaElement] +-- generateSchema = undefined +-- +-- data Metadata = Metadata +-- { schema :: [SchemaElement] +-- , rowGroups :: [RowGroup] +-- } +-- +-- buildMetadata :: Metadata -> Builder +-- buildMetadata = undefined +-- +-- chunkDataFrame :: Int -> DataFrame -> [DataFrame] +-- chunkDataFrame = undefined +-- +-- chunkColumn :: Int -> Column -> [Column] +-- chunkColumn = undefined +-- +-- -- TODO IF it becomes a problem, allocate an array ahead of time for storing the metadata +-- foldChunks :: [chunk] -> (chunk -> WriterState (Builder, metadata)) -> WriterState (Builder, Sequence metadata) +-- foldChunks chunks process = foldl' f (mempty, mempty) chunks +-- where +-- f (builder, metadata) chunk = let (nextBuilder, nextMetadata) = process chunk +-- in (builder <> nextBuilder, metadata <> nextMetadata) +-- state = State +-- ---TODO ColumnIndices and OffsetIndices --- TODO ColumnOrders diff --git a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs index c6b84655..37936091 100644 --- a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs +++ b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs @@ -1,10 +1,13 @@ {-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE OverloadedRecordDot #-} +{-# LANGUAGE TypeFamilies #-} module DataFrame.IO.Utils.RandomAccess where import Control.Monad.IO.Class (MonadIO (..)) import Data.ByteString (ByteString) -import Data.ByteString.Internal (ByteString (PS)) +import Data.ByteString.Internal (ByteString (PS), fromForeignPtr0) +import Data.ByteString.Builder (Builder, byteString) import qualified Data.Vector.Storable as VS import Data.Word (Word8) import DataFrame.IO.Parquet.Seeking ( @@ -16,6 +19,9 @@ import DataFrame.IO.Parquet.Seeking ( import Foreign (castForeignPtr) import System.IO ( SeekMode (AbsoluteSeek), + Handle, + WriteMode, + withBinaryFile ) uncurry3 :: (a -> b -> c -> d) -> (a, b, c) -> d @@ -76,3 +82,102 @@ unsafeToByteString :: VS.Vector Word8 -> ByteString unsafeToByteString v = PS (castForeignPtr ptr) offset' len where (ptr, offset', len) = VS.unsafeToForeignPtr v + +-- Writer Buffer ----------------------------------------------------------------- + +-- Refer to DataFrame.IO.Parquet.Writer for a justification of what we're doing here +-- There's some overlap here with what's going on in Seeking.hs, so, if this bothers +-- us, eventually someone will have to come back and reconcile the writer buffer +-- approach with the reader oriented patterns in Seeking.hs. +-- +-- We're using MutableByteArrays here for convenience and because we don't need +-- the more powerful abstractions vector provides (which uses ByteArrays internally) +-- +-- since we want to use hPutBuf, we're going to need a Ptr, which means are ByteArrya +-- must be pinned. Now growing pinned arrays can be problematic, but in the vast majority +-- of cases we shouldn't be growing more than once, if that. See the docs for +-- Data.Primitive.ByteArray.byteArrayContents. + +newtype WritableBinaryHandle = WritableBinaryHandle { unHandle :: Handle } + +openWritableBinaryFile :: (HasBuffer m) => FilePath -> m WritableBinaryHandle +openWritableBinaryFile filepath = liftIO $ do + h <- openBinaryFile AppendMode + hSetBinaryMode h True + hSetBuffering h $ BlockBuffering (Just 65536) + pure . WritableBinaryHandle $ h + +withWritableBinaryFile :: (HasBuffer m) => FilePath -> (WriteableBinaryHandle -> m r) -> m r +withWritableBinaryFile filepath action = + bracket + (openWritableBinaryFile filepath) + (hClose . unHandle) + action + +class (Monad m) => HasBuffer m where + type Buffer m + askBuffer :: m (Buffer m) + residency :: m Int -- number of bytes currently in the buffer + writeBytes :: (Foldable f) => f Word8 -> m () + flushTo :: Sink -> m () + +data BufferPointer = BufferPointer + { pointer :: !(IORef (ForeignPtr Word8)) -- Reallocatoble if we need to grow + , size :: !(IORef Int) -- Reallocatable if we need to grow it + , cursor :: !(IORef Int) + } + +data MemoryBuffer = MemoryBuffer + { arrayRef :: !(IORef (MutableByteArray RealWorld)) + , positionRef :: !(IORef Int) + } + +data Sink = MemorySink MemoryBuffer | FileSink WritableBinaryHandle + +instance HasBuffer (ReaderIO MemoryBuffer) where + type Buffer (ReaderIO MemoryBuffer) = MemoryBuffer + + askBuffer = ReaderIO id + + residency = ReaderIO $ \buffer -> readIORef buffer.positionRef + + writeBytes bytes = ReaderIO $ \buffer -> do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + length bytes) + newPosition <- foldM (\i byte -> writeByteArray array i byte >> pure (i + 1)) res bytes + writeIORef buffer.positionRef newPosition + + flushTo (MemorySink destination) = ReaderIO $ \source -> + guard (destination /= source) + sourceArray <- readIORef source.arrayRef + sourcePosition <- readIORef source.positionRef + destinationPosition <- readIORef destination.positionRef + let newDestinationPosition = destinationPosition + sourcePosition + destinationArray <- ensureCapacity destination newDestinationPosition + copyMutableByteArray + destinationArray + destinationPosition + sourceArray + 0 -- offset + sourcePosition -- number of bytes + writeIORef destination.positionRef newDestinationPosition + writeIORef source.positionRef 0 + + flushTo (FileSink _) = undefined + +ensureCapacity :: MemoryBuffer -> Int -> IO MutableByteArray +ensureCapacity buffer needed = do + array <- readIORef buffer.arrayRef + if needed <= sizeOfMutableByteArray array + then pure array + else do + grown <- resizeMutableByteArray array (needed + (needed `div` 2)) + writeIORef buffer.arrayRef grown + pure grown + +data BufferHandle = BufferHandle + { handle :: !WritableBinaryHandle + , cursor :: !(IORef Int) + } + + From e8c2b8520ab13b6f98bd484ec42d5d090ac4c41e Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 11:09:15 +0530 Subject: [PATCH 03/21] Implemented HasBuffer instance for the file backed buffer; Cleaned up imports and exports --- dataframe-parquet/dataframe-parquet.cabal | 16 ++ .../src/DataFrame/IO/Utils/RandomAccess.hs | 228 ++++++++++++++---- 2 files changed, 201 insertions(+), 43 deletions(-) diff --git a/dataframe-parquet/dataframe-parquet.cabal b/dataframe-parquet/dataframe-parquet.cabal index 1a377b4c..b0a168de 100644 --- a/dataframe-parquet/dataframe-parquet.cabal +++ b/dataframe-parquet/dataframe-parquet.cabal @@ -55,6 +55,7 @@ library dataframe-core >= 2.1 && < 2.2, dataframe-operations >= 2.1 && < 2.2, dataframe-parsing >= 2.1 && < 2.2, + primitive >= 0.7 && < 0.11, directory >= 1.3.0.0 && < 2, filepath >= 1.4 && < 2, Glob >= 0.10 && < 1, @@ -67,3 +68,18 @@ library zstd >= 0.1.2.0 && < 0.3 hs-source-dirs: src default-language: Haskell2010 + + +test-suite dataframe-parquet-tests + import: warnings + type: exitcode-stdio-1.0 + main-is: Main.hs + hs-source-dirs: tests + build-depends: base >= 4 && < 5, + bytestring >= 0.11 && < 0.14, + dataframe-parquet, + primitive >= 0.7 && < 0.11, + filepath >= 1.4 && < 2, + temporary >= 1.3 && < 1.5, + HUnit >= 1.6 && < 1.8 + default-language: Haskell2010 \ No newline at end of file diff --git a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs index 37936091..cce4afe4 100644 --- a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs +++ b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs @@ -2,12 +2,28 @@ {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE TypeFamilies #-} -module DataFrame.IO.Utils.RandomAccess where +module DataFrame.IO.Utils.RandomAccess ( + uncurry3, + Range (..), + RandomAccess (..), + ReaderIO (runReaderIO), + LocalFile, + MMappedFile, + unsafeToByteString, + WritableBinaryHandle, + openWritableBinaryFile, + withWritableBinaryFile, + HasBuffer (..), + Sink (..), + MemoryBuffer (..), + mallocBuffer, + BufferHandle, + withFileBuffer, +) where import Control.Monad.IO.Class (MonadIO (..)) -import Data.ByteString (ByteString) -import Data.ByteString.Internal (ByteString (PS), fromForeignPtr0) -import Data.ByteString.Builder (Builder, byteString) +import Data.ByteString.Internal (ByteString (PS)) +import qualified Data.Foldable as Foldable import qualified Data.Vector.Storable as VS import Data.Word (Word8) import DataFrame.IO.Parquet.Seeking ( @@ -16,12 +32,32 @@ import DataFrame.IO.Parquet.Seeking ( fSeek, readLastBytes, ) -import Foreign (castForeignPtr) +import Control.Exception (bracket) +import Control.Monad (foldM) +import Control.Monad.Primitive (RealWorld) +import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import Data.Primitive.ByteArray ( + MutableByteArray, + copyMutableByteArray, + getSizeofMutableByteArray, + mutableByteArrayContents, + newPinnedByteArray, + resizeMutableByteArray, + writeByteArray, + ) +import Foreign (castForeignPtr, plusPtr) import System.IO ( - SeekMode (AbsoluteSeek), + BufferMode (NoBuffering), Handle, - WriteMode, - withBinaryFile + IOMode (AppendMode, ReadWriteMode), + SeekMode (AbsoluteSeek), + hClose, + hGetBuf, + hPutBuf, + hSeek, + hSetBinaryMode, + hSetBuffering, + openBinaryFile, ) uncurry3 :: (a -> b -> c -> d) -> (a, b, c) -> d @@ -100,14 +136,14 @@ unsafeToByteString v = PS (castForeignPtr ptr) offset' len newtype WritableBinaryHandle = WritableBinaryHandle { unHandle :: Handle } -openWritableBinaryFile :: (HasBuffer m) => FilePath -> m WritableBinaryHandle -openWritableBinaryFile filepath = liftIO $ do - h <- openBinaryFile AppendMode +openWritableBinaryFile :: FilePath -> IO WritableBinaryHandle +openWritableBinaryFile filepath = do + h <- openBinaryFile filepath AppendMode hSetBinaryMode h True - hSetBuffering h $ BlockBuffering (Just 65536) + hSetBuffering h NoBuffering pure . WritableBinaryHandle $ h -withWritableBinaryFile :: (HasBuffer m) => FilePath -> (WriteableBinaryHandle -> m r) -> m r +withWritableBinaryFile :: FilePath -> (WritableBinaryHandle -> IO a) -> IO a withWritableBinaryFile filepath action = bracket (openWritableBinaryFile filepath) @@ -121,54 +157,91 @@ class (Monad m) => HasBuffer m where writeBytes :: (Foldable f) => f Word8 -> m () flushTo :: Sink -> m () -data BufferPointer = BufferPointer - { pointer :: !(IORef (ForeignPtr Word8)) -- Reallocatoble if we need to grow - , size :: !(IORef Int) -- Reallocatable if we need to grow it - , cursor :: !(IORef Int) - } - data MemoryBuffer = MemoryBuffer { arrayRef :: !(IORef (MutableByteArray RealWorld)) , positionRef :: !(IORef Int) } +mallocBuffer :: Int -> IO MemoryBuffer +mallocBuffer capacity + | capacity < 0 = ioError $ userError "mallocBuffer: negative capacity" + | otherwise = do + array <- newPinnedByteArray capacity + MemoryBuffer <$> newIORef array <*> newIORef 0 + data Sink = MemorySink MemoryBuffer | FileSink WritableBinaryHandle instance HasBuffer (ReaderIO MemoryBuffer) where type Buffer (ReaderIO MemoryBuffer) = MemoryBuffer - askBuffer = ReaderIO id + askBuffer = ReaderIO pure residency = ReaderIO $ \buffer -> readIORef buffer.positionRef writeBytes bytes = ReaderIO $ \buffer -> do position <- readIORef buffer.positionRef - array <- ensureCapacity buffer (position + length bytes) - newPosition <- foldM (\i byte -> writeByteArray array i byte >> pure (i + 1)) res bytes + array <- ensureCapacity buffer (position + Foldable.length bytes) + newPosition <- foldM (\i byte -> writeByteArray array i byte >> pure (i + 1)) position bytes writeIORef buffer.positionRef newPosition flushTo (MemorySink destination) = ReaderIO $ \source -> - guard (destination /= source) - sourceArray <- readIORef source.arrayRef - sourcePosition <- readIORef source.positionRef - destinationPosition <- readIORef destination.positionRef - let newDestinationPosition = destinationPosition + sourcePosition - destinationArray <- ensureCapacity destination newDestinationPosition - copyMutableByteArray - destinationArray - destinationPosition - sourceArray - 0 -- offset - sourcePosition -- number of bytes - writeIORef destination.positionRef newDestinationPosition - writeIORef source.positionRef 0 - - flushTo (FileSink _) = undefined - -ensureCapacity :: MemoryBuffer -> Int -> IO MutableByteArray + if destination.arrayRef == source.arrayRef + then pure () + else do + sourceArray <- readIORef source.arrayRef + sourcePosition <- readIORef source.positionRef + destinationPosition <- readIORef destination.positionRef + let newDestinationPosition = destinationPosition + sourcePosition + destinationArray <- ensureCapacity destination newDestinationPosition + copyMutableByteArray + destinationArray + destinationPosition + sourceArray + 0 -- offset + sourcePosition -- number of bytes + writeIORef destination.positionRef newDestinationPosition + writeIORef source.positionRef 0 + + -- I tested write speeds by doing (on Apple Silicon) + -- `dd if=/dev/zero of=test bs={$n}k oflag=direct conv=fdatasync + -- Results: + -- + -- ``` + -- | block size | data (GiB) | time (s) | GiB/s | + -- |------------|------------|-----------|-------| + -- | 4k | 4.00 | 2.371 | 1.69 | + -- | 8k | 4.00 | 1.486 | 2.69 | + -- | 16k | 4.00 | 1.045 | 3.83 | + -- | 32k | 4.00 | 0.740 | 5.40 | + -- | 64k | 4.00 | 0.675 | 5.92 | + -- | 128k | 4.00 | 0.669 | 5.98 | + -- | 256k | 4.00 | 0.664 | 6.03 | + -- | 512k | 4.00 | 0.670 | 5.97 | + -- | 1024k | 4.00 | 0.664 | 6.02 | + -- | 4096k | 4.00 | 0.668 | 5.99 | + -- ``` + -- So when writing to a file to minimize syscall overhead while + -- trying not to create dirty pages in the kernel page cache, we'll + -- be flushing in 256 KiB chunks. + flushTo (FileSink (WritableBinaryHandle h)) = ReaderIO $ \buffer -> do + array <- readIORef buffer.arrayRef + position <- readIORef buffer.positionRef + let ptr = mutableByteArrayContents array + chunkSize = 262144 -- 256 KiB + go offset + | offset >= position = pure () + | otherwise = do + let n = min chunkSize (position - offset) + hPutBuf h (ptr `plusPtr` offset) n + go (offset + n) + go 0 + writeIORef buffer.positionRef 0 + +ensureCapacity :: MemoryBuffer -> Int -> IO (MutableByteArray RealWorld) ensureCapacity buffer needed = do array <- readIORef buffer.arrayRef - if needed <= sizeOfMutableByteArray array + maxSize <- getSizeofMutableByteArray array -- ensure sequencing in the presence of resizing + if needed <= maxSize then pure array else do grown <- resizeMutableByteArray array (needed + (needed `div` 2)) @@ -176,8 +249,77 @@ ensureCapacity buffer needed = do pure grown data BufferHandle = BufferHandle - { handle :: !WritableBinaryHandle - , cursor :: !(IORef Int) + { bufferPath :: !FilePath + , bufferHandle :: !WritableBinaryHandle + , residencyRef :: !(IORef Int) + , flushedRef :: !(IORef Int) } +withFileBuffer :: FilePath -> (BufferHandle -> IO a) -> IO a +withFileBuffer filepath action = + bracket open (hClose . unHandle . bufferHandle) action + where + open = do + h <- openBinaryFile filepath ReadWriteMode + hSetBinaryMode h True + hSetBuffering h NoBuffering + res <- newIORef 0 + flushed <- newIORef 0 + pure (BufferHandle filepath (WritableBinaryHandle h) res flushed) + +instance HasBuffer (ReaderIO BufferHandle) where + type Buffer (ReaderIO BufferHandle) = BufferHandle + + askBuffer = ReaderIO pure + + residency = ReaderIO $ \bh -> readIORef bh.residencyRef + + writeBytes bytes = ReaderIO $ \bh -> do + let WritableBinaryHandle h = bh.bufferHandle + scratch <- newPinnedByteArray 1 + n <- + foldM + ( \count byte -> do + writeByteArray scratch 0 byte + hPutBuf h (mutableByteArrayContents scratch) 1 + pure (count + 1) + ) + 0 + bytes + position <- readIORef bh.residencyRef + writeIORef bh.residencyRef (position + n) + + -- Flushing from a file to a sink happens using + -- a reusable 256 KiB buffer closed over this function + -- Usually for this instance we shoulc be diong only + -- file to file but file to buffer is also bossible + -- if you should want to do that, for whatever reason + -- (don't let me tell you how to live your life) + flushTo sink = ReaderIO $ \bh -> do + count <- readIORef bh.residencyRef + offset <- readIORef bh.flushedRef + let WritableBinaryHandle h = bh.bufferHandle + chunkSize = 262144 + chunk <- newPinnedByteArray (min chunkSize count) + let ptr = mutableByteArrayContents chunk + pushChunk n = case sink of + FileSink (WritableBinaryHandle out) -> hPutBuf out ptr n + MemorySink dest -> do + destinationPosition <- readIORef dest.positionRef + let newDestinationPosition = destinationPosition + n + destinationArray <- ensureCapacity dest newDestinationPosition + copyMutableByteArray destinationArray destinationPosition chunk 0 n + writeIORef dest.positionRef newDestinationPosition + go remaining + | remaining <= 0 = pure () + | otherwise = do + actual <- hGetBuf h ptr (min chunkSize remaining) + if actual <= 0 + then pure () + else pushChunk actual >> go (remaining - actual) + hSeek h AbsoluteSeek (fromIntegral offset) + go count + writeIORef bh.residencyRef 0 + writeIORef bh.flushedRef (offset + count) + From c3fc6c37d1d80fb41d467ae1f557c96ab5eb6e77 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 11:09:31 +0530 Subject: [PATCH 04/21] Tests for `HasBuffer` instances --- dataframe-parquet/tests/Main.hs | 262 ++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 dataframe-parquet/tests/Main.hs diff --git a/dataframe-parquet/tests/Main.hs b/dataframe-parquet/tests/Main.hs new file mode 100644 index 00000000..4591ee48 --- /dev/null +++ b/dataframe-parquet/tests/Main.hs @@ -0,0 +1,262 @@ +{-# LANGUAGE ScopedTypeVariables #-} + +-- | Tests for the writer-buffer logic in "DataFrame.IO.Utils.RandomAccess". + +module Main where + +import Control.Monad (forM) +import qualified Data.ByteString as BS +import Data.IORef (readIORef) +import Data.Primitive.ByteArray (readByteArray) +import Data.Word (Word8) +import System.FilePath (()) +import qualified System.Exit as Exit +import System.IO.Temp (withSystemTempDirectory) +import Test.HUnit + +import DataFrame.IO.Utils.RandomAccess + +withTempFileBuffer :: FilePath -> String -> (BufferHandle -> IO a) -> IO a +withTempFileBuffer dir name = withFileBuffer (dir name) + +memoryBufferBytes :: MemoryBuffer -> IO [Word8] +memoryBufferBytes buf = do + array <- readIORef (arrayRef buf) + n <- readIORef (positionRef buf) + forM [0 .. n - 1] (readByteArray array) + +residencyTracksWrites :: Test +residencyTracksWrites = TestCase $ + withSystemTempDirectory "dfpq-buffer" $ \dir -> + withTempFileBuffer dir "residency.bin" $ \bh -> do + runReaderIO (writeBytes [1, 2, 3, 4, 5 :: Word8]) bh + r1 <- runReaderIO residency bh + assertEqual "residency after first write" 5 r1 + runReaderIO (writeBytes [6, 7, 8 :: Word8]) bh + r2 <- runReaderIO residency bh + assertEqual "residency accumulates across writes" 8 r2 + +-- Flushing a file buffer into a FileSink writes exactly the buffered bytes to +-- the output handle and empties the buffer. +flushToFileSink :: Test +flushToFileSink = TestCase $ + withSystemTempDirectory "dfpq-buffer" $ \dir -> do + let outPath = dir "out.bin" + payload = [10, 20, 30, 40, 50, 60 :: Word8] + withTempFileBuffer dir "buf.bin" $ \bh -> do + runReaderIO (writeBytes payload) bh + withWritableBinaryFile outPath $ \out -> + runReaderIO (flushTo (FileSink out)) bh + r <- runReaderIO residency bh + assertEqual "residency reset after flush" 0 r + contents <- BS.readFile outPath + assertEqual "flushed file content" (BS.pack payload) contents + +-- Flushing a file buffer into a MemorySink copies the buffered bytes into the +-- destination memory buffer and empties the source. +flushToMemorySink :: Test +flushToMemorySink = TestCase $ + withSystemTempDirectory "dfpq-buffer" $ \dir -> do + let payload = [7, 6, 5, 4, 3, 2, 1 :: Word8] + destination <- mallocBuffer 0 + withTempFileBuffer dir "buf.bin" $ \bh -> do + runReaderIO (writeBytes payload) bh + runReaderIO (flushTo (MemorySink destination)) bh + r <- runReaderIO residency bh + assertEqual "residency reset after flush" 0 r + memBytes <- memoryBufferBytes destination + assertEqual "flushed memory content" payload memBytes + +-- Successive flushes into the same FileSink append rather than overwrite. +flushAppendsToFileSink :: Test +flushAppendsToFileSink = TestCase $ + withSystemTempDirectory "dfpq-buffer" $ \dir -> do + let outPath = dir "out.bin" + chunkA = [1, 2, 3 :: Word8] + chunkB = [4, 5, 6, 7 :: Word8] + withWritableBinaryFile outPath $ \out -> do + withTempFileBuffer dir "a.bin" $ \bh -> do + runReaderIO (writeBytes chunkA) bh + runReaderIO (flushTo (FileSink out)) bh + withTempFileBuffer dir "b.bin" $ \bh -> do + runReaderIO (writeBytes chunkB) bh + runReaderIO (flushTo (FileSink out)) bh + contents <- BS.readFile outPath + assertEqual "appended flushes" (BS.pack (chunkA ++ chunkB)) contents + +-- After a flush the buffer is emptied, so re-using it only re-flushes the +-- bytes written since the previous flush (old bytes are not resurrected). +flushEmptiesForReuse :: Test +flushEmptiesForReuse = TestCase $ + withSystemTempDirectory "dfpq-buffer" $ \dir -> do + let firstPath = dir "first.bin" + secondPath = dir "second.bin" + chunkA = [11, 22, 33 :: Word8] + chunkB = [44, 55 :: Word8] + withTempFileBuffer dir "buf.bin" $ \bh -> do + runReaderIO (writeBytes chunkA) bh + withWritableBinaryFile firstPath $ \out -> + runReaderIO (flushTo (FileSink out)) bh + runReaderIO (writeBytes chunkB) bh + r <- runReaderIO residency bh + assertEqual "residency reflects only post-flush bytes" 2 r + withWritableBinaryFile secondPath $ \out -> + runReaderIO (flushTo (FileSink out)) bh + first <- BS.readFile firstPath + second <- BS.readFile secondPath + assertEqual "first flush" (BS.pack chunkA) first + assertEqual "second flush excludes first chunk" (BS.pack chunkB) second + +-- A payload larger than the 256 KiB flush chunk round-trips intact, exercising +-- the chunked flush loop. +flushLargePayload :: Test +flushLargePayload = TestCase $ + withSystemTempDirectory "dfpq-buffer" $ \dir -> do + let outPath = dir "big.bin" + payload = take 300000 (cycle [0 .. 255]) :: [Word8] + withTempFileBuffer dir "big-buf.bin" $ \bh -> do + runReaderIO (writeBytes payload) bh + r <- runReaderIO residency bh + assertEqual "residency for large payload" 300000 r + withWritableBinaryFile outPath $ \out -> + runReaderIO (flushTo (FileSink out)) bh + contents <- BS.readFile outPath + assertEqual "large payload round-trips" (BS.pack payload) contents + +-- Memory buffer instance ------------------------------------------------------ +-- The same observable contract as the file buffer, plus the memory-specific +-- self-flush no-op. + +-- residency reports the running byte count and accumulates across writes. +memResidencyTracksWrites :: Test +memResidencyTracksWrites = TestCase $ do + buf <- mallocBuffer 0 + runReaderIO (writeBytes [1, 2, 3, 4, 5 :: Word8]) buf + r1 <- runReaderIO residency buf + assertEqual "residency after first write" 5 r1 + runReaderIO (writeBytes [6, 7, 8 :: Word8]) buf + r2 <- runReaderIO residency buf + assertEqual "residency accumulates across writes" 8 r2 + +-- Flushing a memory buffer into a FileSink writes the buffered bytes to the +-- output handle and empties the buffer. +memFlushToFileSink :: Test +memFlushToFileSink = TestCase $ + withSystemTempDirectory "dfpq-buffer" $ \dir -> do + let outPath = dir "out.bin" + payload = [10, 20, 30, 40, 50, 60 :: Word8] + buf <- mallocBuffer 0 + runReaderIO (writeBytes payload) buf + withWritableBinaryFile outPath $ \out -> + runReaderIO (flushTo (FileSink out)) buf + r <- runReaderIO residency buf + assertEqual "residency reset after flush" 0 r + contents <- BS.readFile outPath + assertEqual "flushed file content" (BS.pack payload) contents + +-- Flushing a memory buffer into another MemorySink copies the buffered bytes +-- into the destination and empties the source. +memFlushToMemorySink :: Test +memFlushToMemorySink = TestCase $ do + let payload = [7, 6, 5, 4, 3, 2, 1 :: Word8] + source <- mallocBuffer 0 + destination <- mallocBuffer 0 + runReaderIO (writeBytes payload) source + runReaderIO (flushTo (MemorySink destination)) source + rSrc <- runReaderIO residency source + assertEqual "source residency reset after flush" 0 rSrc + destBytes <- memoryBufferBytes destination + assertEqual "flushed memory content" payload destBytes + +-- Successive flushes into the same FileSink append rather than overwrite. +memFlushAppendsToFileSink :: Test +memFlushAppendsToFileSink = TestCase $ + withSystemTempDirectory "dfpq-buffer" $ \dir -> do + let outPath = dir "out.bin" + chunkA = [1, 2, 3 :: Word8] + chunkB = [4, 5, 6, 7 :: Word8] + withWritableBinaryFile outPath $ \out -> do + bufA <- mallocBuffer 0 + runReaderIO (writeBytes chunkA) bufA + runReaderIO (flushTo (FileSink out)) bufA + bufB <- mallocBuffer 0 + runReaderIO (writeBytes chunkB) bufB + runReaderIO (flushTo (FileSink out)) bufB + contents <- BS.readFile outPath + assertEqual "appended flushes" (BS.pack (chunkA ++ chunkB)) contents + +-- After a flush the buffer is emptied, so re-using it only re-flushes the +-- bytes written since the previous flush. +memFlushEmptiesForReuse :: Test +memFlushEmptiesForReuse = TestCase $ + withSystemTempDirectory "dfpq-buffer" $ \dir -> do + let firstPath = dir "first.bin" + secondPath = dir "second.bin" + chunkA = [11, 22, 33 :: Word8] + chunkB = [44, 55 :: Word8] + buf <- mallocBuffer 0 + runReaderIO (writeBytes chunkA) buf + withWritableBinaryFile firstPath $ \out -> + runReaderIO (flushTo (FileSink out)) buf + runReaderIO (writeBytes chunkB) buf + r <- runReaderIO residency buf + assertEqual "residency reflects only post-flush bytes" 2 r + withWritableBinaryFile secondPath $ \out -> + runReaderIO (flushTo (FileSink out)) buf + first <- BS.readFile firstPath + second <- BS.readFile secondPath + assertEqual "first flush" (BS.pack chunkA) first + assertEqual "second flush excludes first chunk" (BS.pack chunkB) second + +-- A memory buffer flushing to a MemorySink backed by itself is a no-op: the +-- bytes and residency are left untouched (identity is compared via arrayRef). +memSelfFlushIsNoop :: Test +memSelfFlushIsNoop = TestCase $ do + let payload = [3, 1, 4, 1, 5, 9, 2, 6 :: Word8] + buf <- mallocBuffer 0 + runReaderIO (writeBytes payload) buf + runReaderIO (flushTo (MemorySink buf)) buf + r <- runReaderIO residency buf + assertEqual "self-flush leaves residency unchanged" (Prelude.length payload) r + bytes <- memoryBufferBytes buf + assertEqual "self-flush leaves content unchanged" payload bytes + +-- A payload larger than the 256 KiB flush chunk round-trips intact. +memFlushLargePayload :: Test +memFlushLargePayload = TestCase $ + withSystemTempDirectory "dfpq-buffer" $ \dir -> do + let outPath = dir "big.bin" + payload = take 300000 (cycle [0 .. 255]) :: [Word8] + buf <- mallocBuffer 0 + runReaderIO (writeBytes payload) buf + r <- runReaderIO residency buf + assertEqual "residency for large payload" 300000 r + withWritableBinaryFile outPath $ \out -> + runReaderIO (flushTo (FileSink out)) buf + contents <- BS.readFile outPath + assertEqual "large payload round-trips" (BS.pack payload) contents + +tests :: Test +tests = + TestList + [ TestLabel "file buffer: residency tracks writes" residencyTracksWrites + , TestLabel "file buffer: flush to file sink" flushToFileSink + , TestLabel "file buffer: flush to memory sink" flushToMemorySink + , TestLabel "file buffer: flush appends to file sink" flushAppendsToFileSink + , TestLabel "file buffer: flush empties for reuse" flushEmptiesForReuse + , TestLabel "file buffer: flush large payload" flushLargePayload + , TestLabel "memory buffer: residency tracks writes" memResidencyTracksWrites + , TestLabel "memory buffer: flush to file sink" memFlushToFileSink + , TestLabel "memory buffer: flush to memory sink" memFlushToMemorySink + , TestLabel "memory buffer: flush appends to file sink" memFlushAppendsToFileSink + , TestLabel "memory buffer: flush empties for reuse" memFlushEmptiesForReuse + , TestLabel "memory buffer: self-flush is a no-op" memSelfFlushIsNoop + , TestLabel "memory buffer: flush large payload" memFlushLargePayload + ] + +main :: IO () +main = do + result <- runTestTT tests + if failures result > 0 || errors result > 0 + then Exit.exitFailure + else Exit.exitSuccess From f292f51580515e376f529f14e2e840992c28c4fc Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 11:09:47 +0530 Subject: [PATCH 05/21] Changes to make Writer.hs compile so we can run tests --- .../src/DataFrame/IO/Parquet/Writer.hs | 67 ++----------------- 1 file changed, 4 insertions(+), 63 deletions(-) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs index 00b30dae..9aa22744 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs @@ -3,8 +3,7 @@ module DataFrame.IO.Parquet.Writer (writeParquet, writeParquetWithOptions, defaultParquetWriteOptions) where -import qualified Data.Vector as Vector -import Data.Vector (Vector) +import DataFrame.Internal.DataFrame (DataFrame) --A Parquet file is a series of row groups followed by the file metadata (which contains the schema and the -- metadata for all the rowgroups, which, in turn, contain the metadata for each column chunk). Inside each @@ -22,9 +21,6 @@ data ParquetWriteOptions = ParquetWriteOptions { pageSize :: Int , rowGroupSize :: Int , batchSize :: Int - , preferredCompressionCodec :: CompressionCodec - , rowGroupBuffer :: RowGroupBuffer - , } deriving (Eq, Show) @@ -70,82 +66,27 @@ defaultParquetWriteOptions = undefined -- -- refer to DataFrame.IO.Utils.RandomAccess for the buffer implementation. --- I tested write speeds by doing (on Apple Silicon) --- `dd if=/dev/zero of=test bs={$n}k oflag=direct conv=fdatasync --- Results: --- --- ``` --- | block size | data (GiB) | time (s) | GiB/s | --- |------------|------------|-----------|-------| --- | 4k | 4.00 | 2.371 | 1.69 | --- | 8k | 4.00 | 1.486 | 2.69 | --- | 16k | 4.00 | 1.045 | 3.83 | --- | 32k | 4.00 | 0.740 | 5.40 | --- | 64k | 4.00 | 0.675 | 5.92 | --- | 128k | 4.00 | 0.669 | 5.98 | --- | 256k | 4.00 | 0.664 | 6.03 | --- | 512k | 4.00 | 0.670 | 5.97 | --- | 1024k | 4.00 | 0.664 | 6.02 | --- | 4096k | 4.00 | 0.668 | 5.99 | --- ``` --- We see that our raw data-writing throughput caps out at 64k and declines slightly above that. So we --- need to split our buffer into 64k chunks and flush each to dis k(of course, this --- may very well vary from machine to machine, regardless, 64KiB is probably good enough). --- - - -- We need writers for each level of the Parquet file -- A RowGroupWriter that flushes into the file -data RowGroupEnv = RowGroupEnv - { rowGroupBuffer :: !(Vector ColumnChunkWriter) - , pageWriter :: PageWriter - } -type RowGroupWriter b a = ReaderIO (RowGroupEnv b) a -- The RowGroupWriter has in its env a Vector of ColumnChunkWriters -- (which is essentially the row group buffer) -type ColumnChunkWriter = ReaderIO -- And the RowGroupWriter also needs a PageWriter -type PageWriter = ReaderIO writeParquet :: FilePath -> DataFrame -> IO () writeParquet = writeParquetWithOptions defaultParquetWriteOptions writeParquetWithOptions :: ParquetWriteOptions -> FilePath -> DataFrame -> IO () -writeParquetWithOptions options filepath dataframe = do - let - initialState = WriterStateRecord 0 options.rowGroupSize emptyColumnChunkState - (_, (pages, metadata)) = runState initialState - $ foldChunks (chunkDataFrame options.rowGroupSize dataframe) generateRowGroup - schema = generateSchema dataframe - writeFile filepath (pages <> buildMetadata metadata) +writeParquetWithOptions _options _filepath _dataframe = undefined -- I don't see how this scales to multiple reader/writer threads so we may have -- to change this later -data WriterStateRecord = WriterStateRecord - { offset :: Int64 - , chunkSize :: Int64 - , columnChunkState :: ColumnChunkStateRecord - } - -data ColumnChunkStateRecord = ColumnChunkStateRecord - { thriftType :: ThriftType - , encodings :: Set Encoding - , codec :: CompressionCodec - , total_uncompressed_size :: Int64 - , total_compressed_size :: Int64 - , data_page_offset :: Maybe Int64 - , dictionary_page_offset :: Maybe Int64 - } -initColumnChunkState :: Column -> ColumnChunkStateRecord -initColumnChunkState = undefined - -emptyColumnChunkState :: ColumnChunkStateRecord -emptyColumnChunkState = undefined + + -- type WriterState a = State WriterStateRecord a -- From 167ab0807dbf45621ad60e9e13e4a22d267bf6f1 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 12:45:38 +0530 Subject: [PATCH 06/21] Fixed ensureCapacity so that it works correctly with pinned ByteArrays and added some helper functions. --- .../src/DataFrame/IO/Utils/RandomAccess.hs | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs index cce4afe4..afd51b30 100644 --- a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs +++ b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs @@ -19,11 +19,14 @@ module DataFrame.IO.Utils.RandomAccess ( mallocBuffer, BufferHandle, withFileBuffer, + appendByteString, + appendByteStringHandle, ) where import Control.Monad.IO.Class (MonadIO (..)) import Data.ByteString.Internal (ByteString (PS)) import qualified Data.Foldable as Foldable +import qualified Data.ByteString.Unsafe as BU import qualified Data.Vector.Storable as VS import Data.Word (Word8) import DataFrame.IO.Parquet.Seeking ( @@ -42,10 +45,10 @@ import Data.Primitive.ByteArray ( getSizeofMutableByteArray, mutableByteArrayContents, newPinnedByteArray, - resizeMutableByteArray, + withMutableByteArrayContents, writeByteArray, ) -import Foreign (castForeignPtr, plusPtr) +import Foreign (castForeignPtr, castPtr, copyBytes, plusPtr) import System.IO ( BufferMode (NoBuffering), Handle, @@ -226,17 +229,35 @@ instance HasBuffer (ReaderIO MemoryBuffer) where flushTo (FileSink (WritableBinaryHandle h)) = ReaderIO $ \buffer -> do array <- readIORef buffer.arrayRef position <- readIORef buffer.positionRef - let ptr = mutableByteArrayContents array - chunkSize = 262144 -- 256 KiB - go offset - | offset >= position = pure () - | otherwise = do - let n = min chunkSize (position - offset) - hPutBuf h (ptr `plusPtr` offset) n - go (offset + n) - go 0 + withMutableByteArrayContents array $ \ptr -> do + let chunkSize = 262144 + go offset + | offset >= position = pure () + | otherwise = do + let n = min chunkSize (position - offset) + hPutBuf h (ptr `plusPtr` offset) n + go (offset + n) + go 0 writeIORef buffer.positionRef 0 +-- We're using pinned ByteArrays so we must +-- not use the grow fuynction brovided by primitive +-- instead we must alloocatie a new pinned byteArray. +-- We might have been worried about heap fragmentation +-- becasue a single pinned object in a 4KB GHC block can +-- keep the whole plock alive but oyr buffers will tend to +-- be much larger than that. +-- But the memory useage will temporarily spike to 2.5x the size of +-- the buffer, but it should be fine since the current writer is single threaded +-- and grows *should* be rare (we also allocate a little extra space to +-- begin with). +-- If it becomes an issue we should start tracking an array of pointers +-- to buffers intsead of replacing them wholesale so grwoing a buffer +-- is just a matter of adding a new buffer to the array (which we can +-- pre-allocate to three elements to begin with and grow it only on the +-- off chance that a buffer required more than three grows). The extra +-- ceremony of handling writes and flushes can be encapsulated well in +-- HasBuffer instances. ensureCapacity :: MemoryBuffer -> Int -> IO (MutableByteArray RealWorld) ensureCapacity buffer needed = do array <- readIORef buffer.arrayRef @@ -244,10 +265,22 @@ ensureCapacity buffer needed = do if needed <= maxSize then pure array else do - grown <- resizeMutableByteArray array (needed + (needed `div` 2)) + position <- readIORef buffer.positionRef + grown <- newPinnedByteArray (needed + (needed `div` 2)) + copyMutableByteArray grown 0 array 0 position writeIORef buffer.arrayRef grown pure grown + +appendByteString :: MemoryBuffer -> ByteString -> IO () +appendByteString buffer bs = + BU.unsafeUseAsCStringLen bs $ \(source, len) -> do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + len) + withMutableByteArrayContents array $ \dst -> + copyBytes (dst `plusPtr` position) (castPtr source) len + writeIORef buffer.positionRef (position + len) + data BufferHandle = BufferHandle { bufferPath :: !FilePath , bufferHandle :: !WritableBinaryHandle @@ -323,3 +356,12 @@ instance HasBuffer (ReaderIO BufferHandle) where writeIORef bh.flushedRef (offset + count) + + +appendByteStringHandle :: BufferHandle -> ByteString -> IO () +appendByteStringHandle bh bs = + BU.unsafeUseAsCStringLen bs $ \(source, len) -> do + let WritableBinaryHandle h = bh.bufferHandle + hPutBuf h source len + position <- readIORef bh.residencyRef + writeIORef bh.residencyRef (position + len) From 6fd2e80f319b00dcd5e91fab445943b20f5797d9 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 16:12:24 +0530 Subject: [PATCH 07/21] implement a cleaned up Parquet Writer --- dataframe-parquet/dataframe-parquet.cabal | 6 + .../src/DataFrame/IO/Parquet/Writer.hs | 300 ++++++++++++------ .../IO/Parquet/Writer/ColumnChunkWriter.hs | 162 ++++++++++ .../DataFrame/IO/Parquet/Writer/DefLevels.hs | 57 ++++ .../DataFrame/IO/Parquet/Writer/Encoder.hs | 197 ++++++++++++ .../DataFrame/IO/Parquet/Writer/Metadata.hs | 68 ++++ .../DataFrame/IO/Parquet/Writer/Options.hs | 32 ++ .../DataFrame/IO/Parquet/Writer/PageWriter.hs | 115 +++++++ .../src/DataFrame/IO/Utils/RandomAccess.hs | 142 ++++++++- dataframe-parquet/tests/Main.hs | 42 +++ .../tests/data/alltypes_dictionary.parquet | Bin 0 -> 1698 bytes .../tests/data/alltypes_plain.parquet | Bin 0 -> 1851 bytes .../tests/data/alltypes_plain.snappy.parquet | Bin 0 -> 1736 bytes .../tests/data/alltypes_tiny_pages.parquet | Bin 0 -> 454233 bytes .../tests/data/int32_decimal.parquet | Bin 0 -> 478 bytes .../tests/data/int64_decimal.parquet | Bin 0 -> 591 bytes dataframe-parquet/tests/data/mtcars.parquet | Bin 0 -> 4564 bytes .../tests/data/transactions.parquet | Bin 0 -> 1746 bytes 18 files changed, 1014 insertions(+), 107 deletions(-) create mode 100644 dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs create mode 100644 dataframe-parquet/src/DataFrame/IO/Parquet/Writer/DefLevels.hs create mode 100644 dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs create mode 100644 dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Metadata.hs create mode 100644 dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Options.hs create mode 100644 dataframe-parquet/src/DataFrame/IO/Parquet/Writer/PageWriter.hs create mode 100644 dataframe-parquet/tests/data/alltypes_dictionary.parquet create mode 100644 dataframe-parquet/tests/data/alltypes_plain.parquet create mode 100644 dataframe-parquet/tests/data/alltypes_plain.snappy.parquet create mode 100644 dataframe-parquet/tests/data/alltypes_tiny_pages.parquet create mode 100644 dataframe-parquet/tests/data/int32_decimal.parquet create mode 100644 dataframe-parquet/tests/data/int64_decimal.parquet create mode 100644 dataframe-parquet/tests/data/mtcars.parquet create mode 100644 dataframe-parquet/tests/data/transactions.parquet diff --git a/dataframe-parquet/dataframe-parquet.cabal b/dataframe-parquet/dataframe-parquet.cabal index d8f72eea..e7a9a987 100644 --- a/dataframe-parquet/dataframe-parquet.cabal +++ b/dataframe-parquet/dataframe-parquet.cabal @@ -45,6 +45,12 @@ library DataFrame.IO.Parquet.Time DataFrame.IO.Parquet.Utils DataFrame.IO.Parquet.Writer + DataFrame.IO.Parquet.Writer.ColumnChunkWriter + DataFrame.IO.Parquet.Writer.DefLevels + DataFrame.IO.Parquet.Writer.Encoder + DataFrame.IO.Parquet.Writer.Metadata + DataFrame.IO.Parquet.Writer.Options + DataFrame.IO.Parquet.Writer.PageWriter DataFrame.IO.Utils.RandomAccess DataFrame.Typed.IO.Parquet build-depends: base >= 4 && < 5, diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs index 9aa22744..422ad439 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs @@ -1,9 +1,56 @@ -{-# LANGUAGE TupleSections #-} -{-# LANGUAGE LambdaCase #-} - -module DataFrame.IO.Parquet.Writer (writeParquet, writeParquetWithOptions, defaultParquetWriteOptions) where - -import DataFrame.Internal.DataFrame (DataFrame) +{-# LANGUAGE OverloadedRecordDot #-} +{-# LANGUAGE OverloadedStrings #-} + +module DataFrame.IO.Parquet.Writer ( + writeParquet, + writeParquetWithOptions, + ParquetWriteOptions (..), + WriterStrategy (..), + defaultParquetWriteOptions, +) where + +import Control.Monad (when) +import qualified Data.ByteString as BS +import Data.Int (Int64) +import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef) +import Data.Maybe (fromJust) +import qualified Data.Vector as VB +import DataFrame.IO.Parquet.Thrift +import DataFrame.IO.Parquet.Writer.ColumnChunkWriter ( + ColumnChunkState (..), + bufferedSize, + finalizePage, + initColumnState, + maybeFinalizePage, + runColumnChunkWriter, + writeRow, + ) +import DataFrame.IO.Parquet.Writer.Encoder (Encoder (..)) +import DataFrame.IO.Parquet.Writer.Metadata (magic, rootSchemaElement) +import DataFrame.IO.Parquet.Writer.Options ( + ParquetWriteOptions (..), + WriterStrategy (..), + defaultParquetWriteOptions, + ) +import DataFrame.IO.Utils.RandomAccess ( + WritableBinaryHandle, + bufferResidency, + flushBufferToFile, + mallocBuffer, + onBuffer, + putByteString, + putWord32LE, + withWritableBinaryFile, + writeByteStringToFile, + ) +import DataFrame.Internal.DataFrame ( + DataFrame, + columnNames, + dataframeDimensions, + getColumn, + ) +import Pinch (enum, putField) +import qualified Pinch --A Parquet file is a series of row groups followed by the file metadata (which contains the schema and the -- metadata for all the rowgroups, which, in turn, contain the metadata for each column chunk). Inside each @@ -16,17 +63,6 @@ import DataFrame.Internal.DataFrame (DataFrame) -- factors to the user so they can tune the writer to have the behavior they want. (there are subtleties to -- this that are discussed further below) -- - -data ParquetWriteOptions = ParquetWriteOptions - { pageSize :: Int - , rowGroupSize :: Int - , batchSize :: Int - } - deriving (Eq, Show) - -defaultParquetWriteOptions :: ParquetWriteOptions -defaultParquetWriteOptions = undefined - -- We'll set the default Page size to 1 MiB and the default rowGroupSize to 128MiB (of course users will be -- able to adjust these numbers through write options). We need to hold the entire RowGroup in memory as -- we build is as the ColumnChunks need to be contiguous when written to disk. So we need to hold @@ -45,16 +81,16 @@ defaultParquetWriteOptions = undefined -- -- First we must consider the page size and row group sizes to be best effort. They could be slightly above -- or below the target. The characteristics of the parquet file will depend on both the write options and --- the specific data being encoded. Second, we should run batches of rows through the writer, flushing when --- we see that a page has met or exceeded its limit, and when a row group has done the same. So a row group --- is flushed specifically only on batch boundaries and we get the same number of rows in every row group --- except the last which will be smaller than the rest. +-- the specific data being encoded. Arrow-rs runs batches of rows through the writer, flushing when +-- they see that a page/rowgroup has met or exceeded its limit. -- --- But we should also not overshoot page size egregiously if the user sets a large batch size, so we can --- batch the batches (sub-batch) and make it configurable so that page sizes can be tuned if needed. Note: --- arrow-rs had something similar but they ran into issues where some columns had really large values. --- See https://github.com/apache/arrow-rs/issues/10061. We may need to implement this eventually, but --- I'm too lazy to do it right now. +-- So a row group is flushed specifically only on batch boundaries and we get the same number of rows in +-- every row group except the last which will be smaller than the rest. They also use sub batching. so +-- as to not overshoot page size egregiously if the user sets a large batch size. Note: +-- arrow-rs had an issue where some columns had really large values. +-- See https://github.com/apache/arrow-rs/issues/10061. +-- +-- We may need to implement batching and sub batching eventually but I'm too lazy to do it right now. -- -- If larger row groups are required (up to a gigabyte in size if not more), we should provide users who -- need to minimize memory usage an alternate two pass strategy where we first write to temporary files (one @@ -64,87 +100,143 @@ defaultParquetWriteOptions = undefined -- user chooses the two pass strategy anyway, the temp files will tend to be held in the OS Page Cache (RAM) -- anyway. -- --- refer to DataFrame.IO.Utils.RandomAccess for the buffer implementation. - --- We need writers for each level of the Parquet file - --- A RowGroupWriter that flushes into the file - - --- The RowGroupWriter has in its env a Vector of ColumnChunkWriters --- (which is essentially the row group buffer) - --- And the RowGroupWriter also needs a PageWriter +-- Niceties like statistics and bloom filters and so on have not yet been implemented. We may need some +-- extra machinery to keep track of row ranges so we can use them with the dataframe to generate our +-- statistics. +-- +-- We haven't yet implemented all the encodings and compressions possible. The writer should first +-- be brought to parity with the reader, and then we should implement encodings and compressions in +-- both together so neither lags behind the other. writeParquet :: FilePath -> DataFrame -> IO () writeParquet = writeParquetWithOptions defaultParquetWriteOptions writeParquetWithOptions :: ParquetWriteOptions -> FilePath -> DataFrame -> IO () -writeParquetWithOptions _options _filepath _dataframe = undefined - --- I don't see how this scales to multiple reader/writer threads so we may have --- to change this later - - - - --- type WriterState a = State WriterStateRecord a --- --- generateRowGroup :: DataFrame -> WriterState (Builder, RowGroup) --- generateRowGroup dataframe = do --- (builder, columns) <- foldChunks columns generateColumnChunk --- --TODO big memory if big columns. Use vectors instead --- let total_byte_size = sum . map (total_uncompressed_size . cc_meta_data) $ columns --- num_rows = fst . dataframeDimensions $ dataframe --- total_uncompressed_size = Just $ sum. map (total_uncompressed_size . cc_meta_data) $ columns --- rowGroup = undefined --TODO --- return (builder, rowGroup) --- --- --- generateColumnChunk :: Column -> WriterState (Builder, ColumnChunk) --- generateColumnChunk column = do --- writerState <- get --- put writerState{columnChunkState = initColumnChunkState column} --- (builder, _) <- foldChunks (chunkColumn writerState.chunkSize column) generatePage --- let file_path = Nothing --- file_offset = 0 --- meta_data = undefined --- offset_index_offset = Nothing --- offset_index_length = Nothing --- column_index_offset = Nothing --- column_index_length = Nothing --- crypto_metadata = Nothing --- encrypted_column_metadata = Nothing --- columnChunk = undefined -- TODO --- return (builder, columnChunk) --- --- --- generatePage :: Column -> WriterState (Builder, PageHeader) -- PageHeader is also already in the builder --- generatePage = undefined --- --- generateSchema :: DataFrame -> [SchemaElement] --- generateSchema = undefined --- --- data Metadata = Metadata --- { schema :: [SchemaElement] --- , rowGroups :: [RowGroup] --- } --- --- buildMetadata :: Metadata -> Builder --- buildMetadata = undefined --- --- chunkDataFrame :: Int -> DataFrame -> [DataFrame] --- chunkDataFrame = undefined --- --- chunkColumn :: Int -> Column -> [Column] --- chunkColumn = undefined --- --- -- TODO IF it becomes a problem, allocate an array ahead of time for storing the metadata --- foldChunks :: [chunk] -> (chunk -> WriterState (Builder, metadata)) -> WriterState (Builder, Sequence metadata) --- foldChunks chunks process = foldl' f (mempty, mempty) chunks --- where --- f (builder, metadata) chunk = let (nextBuilder, nextMetadata) = process chunk --- in (builder <> nextBuilder, metadata <> nextMetadata) --- state = State --- +writeParquetWithOptions opts path df = do + when (opts.strategy == TwoPass) $ + error "writeParquet: TwoPass strategy is not yet implemented" + let (nRows, _) = dataframeDimensions df + names = columnNames df + cols <- VB.fromList <$> mapM (\n -> initColumnState opts n (fromJust (getColumn n df))) names + withWritableBinaryFile path $ \out -> do + writeByteStringToFile out magic + fileOff <- newIORef 4 + rowGroupsRef <- newIORef [] -- Row Group Metadata + rgRowsRef <- newIORef 0 + let st = WriterState out cols fileOff rowGroupsRef rgRowsRef + interval = max 1 opts.batchRows + loop row + | row >= nRows = pure () + | otherwise = do + -- go row by row and append to a pgee + -- When a page is full (frome the page size writer option) flush it to its ColumnChunk + -- When all the columnChunks combined match or exceed the row group size option + -- flush all the columnchunks to file one by one + VB.forM_ cols (runColumnChunkWriter (writeRow row >> maybeFinalizePage opts)) + modifyIORef' rgRowsRef (+ 1) + when ((row + 1) `mod` interval == 0) $ do + size <- bufferedSize cols + when (size >= opts.rowGroupSize) (finalizeRowGroup opts st) + loop (row + 1) + loop 0 + finalizeRowGroup opts st + writeFooter st nRows + +data WriterState = WriterState + { wsOut :: !WritableBinaryHandle + , wsCols :: !(VB.Vector ColumnChunkState) + , wsFileOffset :: !(IORef Int64) + , wsRowGroups :: !(IORef [RowGroup]) + , wsRgRows :: !(IORef Int) + } +finalizeRowGroup :: ParquetWriteOptions -> WriterState -> IO () +finalizeRowGroup opts st = do + rgRows <- readIORef st.wsRgRows + when (rgRows > 0) $ do + VB.mapM_ (runColumnChunkWriter (finalizePage opts.compressionCodec)) st.wsCols + (chunksRev, total) <- + VB.foldM' + (\(acc, totalSize) cs -> do + offset <- readIORef st.wsFileOffset + size <- bufferResidency (ckBuffer cs) + uncompressed <- readIORef (ckUncompressed cs) + flushBufferToFile st.wsOut (ckBuffer cs) + writeIORef st.wsFileOffset (offset + fromIntegral size) + writeIORef (ckUncompressed cs) 0 + let chunk = mkColumnChunk opts offset size uncompressed rgRows cs + pure (chunk : acc, totalSize + fromIntegral size) + ) + ([], 0 :: Int64) + st.wsCols + modifyIORef' st.wsRowGroups (mkRowGroup (reverse chunksRev) total rgRows :) + writeIORef st.wsRgRows 0 + +mkColumnChunk :: ParquetWriteOptions -> Int64 -> Int -> Int64 -> Int -> ColumnChunkState -> ColumnChunk +mkColumnChunk opts offset size uncompressed rgRows cs = + ColumnChunk + { cc_file_path = putField Nothing + , cc_file_offset = putField offset + , cc_meta_data = putField (Just metadata) + , cc_offset_index_offset = putField Nothing + , cc_offset_index_length = putField Nothing + , cc_column_index_offset = putField Nothing + , cc_column_index_length = putField Nothing + , cc_crypto_metadata = putField Nothing + , cc_encrypted_column_metadata = putField Nothing + } + where + metadata = + ColumnMetaData + { cmd_type = putField (ckEncoder cs).encType + , cmd_encodings = putField [PLAIN enum, RLE enum] + , cmd_path_in_schema = putField [ckName cs] + , cmd_codec = putField opts.compressionCodec + , cmd_num_values = putField (fromIntegral rgRows) + , cmd_total_uncompressed_size = putField uncompressed + , cmd_total_compressed_size = putField (fromIntegral size) + , cmd_key_value_metadata = putField Nothing + , cmd_data_page_offset = putField offset + , cmd_index_page_offset = putField Nothing + , cmd_dictionary_page_offset = putField Nothing + , cmd_statistics = putField Nothing + , cmd_encoding_stats = putField Nothing + , cmd_bloom_filter_offset = putField Nothing + , cmd_bloom_filter_length = putField Nothing + } + +mkRowGroup :: [ColumnChunk] -> Int64 -> Int -> RowGroup +mkRowGroup chunks total rgRows = + RowGroup + { rg_columns = putField chunks + , rg_total_byte_size = putField total + , rg_num_rows = putField (fromIntegral rgRows) + , rg_sorting_columns = putField Nothing + , rg_file_offset = putField Nothing + , rg_total_compressed_size = putField (Just total) + , rg_ordinal = putField Nothing + } + +writeFooter :: WriterState -> Int -> IO () +writeFooter st nRows = do + rowGroups <- reverse <$> readIORef st.wsRowGroups + let schemaElements = rootSchemaElement (VB.length st.wsCols) : VB.toList (VB.map ckSchema st.wsCols) + metadata = + FileMetadata + { version = putField 1 + , schema = putField schemaElements + , num_rows = putField (fromIntegral nRows) + , row_groups = putField rowGroups + , key_value_metadata = putField Nothing + , created_by = putField (Just "dataframe-parquet") + , column_orders = putField Nothing + , encryption_algorithm = putField Nothing + , footer_signing_key_metadata = putField Nothing + } + footer = Pinch.encode Pinch.compactProtocol metadata + buffer <- mallocBuffer (BS.length footer + 8) + onBuffer buffer $ do + putByteString footer + putWord32LE (fromIntegral (BS.length footer)) + putByteString magic + flushBufferToFile st.wsOut buffer diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs new file mode 100644 index 00000000..59bc3a1c --- /dev/null +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs @@ -0,0 +1,162 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedRecordDot #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeFamilies #-} + +module DataFrame.IO.Parquet.Writer.ColumnChunkWriter ( + ColumnChunkWriter (..), + ColumnChunkState (..), + page, + askColumnChunk, + initColumnState, + writeRow, + maybeFinalizePage, + finalizePage, + bufferedSize, +) where + +import Control.Monad (when) +import Control.Monad.IO.Class (MonadIO (..)) +import qualified Data.ByteString as BS +import Data.Int (Int64) +import Data.IORef (IORef, modifyIORef', newIORef, readIORef) +import qualified Data.Text as T +import qualified Data.Vector as VB +import DataFrame.IO.Parquet.Thrift +import DataFrame.IO.Parquet.Writer.DefLevels (DefLevels (..)) +import DataFrame.IO.Parquet.Writer.Encoder (Encoder (..), buildEncoder) +import DataFrame.IO.Parquet.Writer.Metadata (mkDataPageHeader, mkSchemaElem) +import DataFrame.IO.Parquet.Writer.Options (ParquetWriteOptions (..)) +import DataFrame.IO.Parquet.Writer.PageWriter ( + PageState (..), + PageWriter (..), + assemblePageBody, + bumpRows, + newPageState, + pageRows, + recordDef, + resetPage, + ) +import DataFrame.IO.Utils.RandomAccess ( + HasBuffer (..), + MemoryBuffer, + ReaderIO (runReaderIO), + Sink (..), + bufferResidency, + bufferToByteString, + copyBuffer, + mallocBuffer, + putByteString, + ) +import DataFrame.Internal.Column (Column, hasMissing) +import qualified Pinch +import qualified Snappy + +data ColumnChunkState = ColumnChunkState + { ckName :: !T.Text + , ckNullable :: !Bool + , ckSchema :: !SchemaElement + , ckEncoder :: !Encoder + , ckBuffer :: !MemoryBuffer + , ckUncompressed :: !(IORef Int64) + , ckPage :: !PageState + } + +newtype ColumnChunkWriter a = ColumnChunkWriter {runColumnChunkWriter :: ColumnChunkState -> IO a} + +instance Functor ColumnChunkWriter where + fmap f (ColumnChunkWriter g) = ColumnChunkWriter (fmap f . g) + +instance Applicative ColumnChunkWriter where + pure x = ColumnChunkWriter (const (pure x)) + ColumnChunkWriter f <*> ColumnChunkWriter g = ColumnChunkWriter (\s -> f s <*> g s) + +instance Monad ColumnChunkWriter where + ColumnChunkWriter m >>= k = ColumnChunkWriter (\s -> m s >>= \a -> runColumnChunkWriter (k a) s) + +instance MonadIO ColumnChunkWriter where + liftIO io = ColumnChunkWriter (const io) + +instance HasBuffer ColumnChunkWriter where + type Buffer ColumnChunkWriter = MemoryBuffer + askBuffer = ColumnChunkWriter (pure . ckBuffer) + residency = ColumnChunkWriter (bufferResidency . ckBuffer) + writeBytes bytes = ColumnChunkWriter (\cs -> runReaderIO (writeBytes bytes) (ckBuffer cs)) + flushTo sink = ColumnChunkWriter (\cs -> runReaderIO (flushTo sink) (ckBuffer cs)) + +page :: PageWriter a -> ColumnChunkWriter a +page (PageWriter f) = ColumnChunkWriter (f . ckPage) + +askColumnChunk :: ColumnChunkWriter ColumnChunkState +askColumnChunk = ColumnChunkWriter pure + +initColumnState :: ParquetWriteOptions -> T.Text -> Column -> IO ColumnChunkState +initColumnState opts name col = do + encoder <- buildEncoder col + let nullable = hasMissing col + schemaElem = mkSchemaElem name encoder.encType nullable encoder.encConverted encoder.encLogical + cap = max 1 opts.pageSize + chunk <- mallocBuffer cap + uncompressed <- newIORef 0 + pageState <- newPageState cap nullable + pure + ColumnChunkState + { ckName = name + , ckNullable = nullable + , ckSchema = schemaElem + , ckEncoder = encoder + , ckBuffer = chunk + , ckUncompressed = uncompressed + , ckPage = pageState + } + +writeRow :: Int -> ColumnChunkWriter () +writeRow row = do + st <- askColumnChunk + present <- page (encWriteValue (ckEncoder st) row) + page $ do + when (ckNullable st) (recordDef present) + bumpRows + +maybeFinalizePage :: ParquetWriteOptions -> ColumnChunkWriter () +maybeFinalizePage opts = do + size <- page residency + when (size >= opts.pageSize) (finalizePage opts.compressionCodec) + +finalizePage :: CompressionCodec -> ColumnChunkWriter () +finalizePage codec = do + st <- askColumnChunk + rows <- page pageRows + when (rows > 0) $ do + page (encFinishValues (ckEncoder st)) + body <- page (assemblePageBody (ckNullable st)) + writeDataPage codec rows body + page resetPage + +writeDataPage :: CompressionCodec -> Int -> MemoryBuffer -> ColumnChunkWriter () +writeDataPage codec rows body = do + uncompressedSize <- liftIO (bufferResidency body) + (compressedSize, emit) <- case codec of + UNCOMPRESSED _ -> pure (uncompressedSize, copyBuffer body) + SNAPPY _ -> do + compressed <- liftIO (Snappy.compress <$> bufferToByteString body) + pure (BS.length compressed, putByteString compressed) + other -> error ("writeParquet: unsupported codec " <> show other) + let headerBytes = Pinch.encode Pinch.compactProtocol (mkDataPageHeader rows uncompressedSize compressedSize) + putByteString headerBytes + emit + bumpUncompressed (fromIntegral (BS.length headerBytes + uncompressedSize)) + +bumpUncompressed :: Int64 -> ColumnChunkWriter () +bumpUncompressed n = ColumnChunkWriter (\st -> modifyIORef' (ckUncompressed st) (+ n)) + +bufferedSize :: VB.Vector ColumnChunkState -> IO Int +bufferedSize = + VB.foldM' + (\total st -> do + chunk <- bufferResidency (ckBuffer st) + values <- bufferResidency (psValues (ckPage st)) + defs <- bufferResidency (ckPage st).psDefs.dlBuf + pure (total + chunk + values + defs) + ) + 0 diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/DefLevels.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/DefLevels.hs new file mode 100644 index 00000000..c8b6ffca --- /dev/null +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/DefLevels.hs @@ -0,0 +1,57 @@ +{-# LANGUAGE OverloadedRecordDot #-} + +module DataFrame.IO.Parquet.Writer.DefLevels ( + DefLevels (..), + newDefLevels, + pushDef, + flushDef, +) where + +import Control.Monad (when) +import Data.Bits (shiftL, shiftR, (.&.), (.|.)) +import Data.IORef (IORef, newIORef, readIORef, writeIORef) +import Data.Word (Word64) +import DataFrame.IO.Utils.RandomAccess (MemoryBuffer, mallocBuffer, writeWord8) + +data DefLevels = DefLevels + { dlBuf :: !MemoryBuffer + , dlValue :: !(IORef Int) + , dlCount :: !(IORef Int) + } + +newDefLevels :: IO DefLevels +newDefLevels = DefLevels <$> mallocBuffer 64 <*> newIORef 0 <*> newIORef 0 + +pushDef :: DefLevels -> Int -> IO () +pushDef dl value = do + count <- readIORef dl.dlCount + if count == 0 + then writeIORef dl.dlValue value >> writeIORef dl.dlCount 1 + else do + current <- readIORef dl.dlValue + if current == value + then writeIORef dl.dlCount (count + 1) + else do + writeDefRun dl current count + writeIORef dl.dlValue value + writeIORef dl.dlCount 1 + +flushDef :: DefLevels -> IO () +flushDef dl = do + count <- readIORef dl.dlCount + when (count > 0) $ do + value <- readIORef dl.dlValue + writeDefRun dl value count + writeIORef dl.dlCount 0 + +writeDefRun :: DefLevels -> Int -> Int -> IO () +writeDefRun dl value count = do + writeLeb128 dl.dlBuf (fromIntegral (count `shiftL` 1)) + writeWord8 dl.dlBuf (fromIntegral value) + +writeLeb128 :: MemoryBuffer -> Word64 -> IO () +writeLeb128 buffer value + | value < 0x80 = writeWord8 buffer (fromIntegral value) + | otherwise = do + writeWord8 buffer (fromIntegral (value .&. 0x7f) .|. 0x80) + writeLeb128 buffer (value `shiftR` 7) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs new file mode 100644 index 00000000..b2613af4 --- /dev/null +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs @@ -0,0 +1,197 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE OverloadedRecordDot #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeOperators #-} + +module DataFrame.IO.Parquet.Writer.Encoder ( + Encoder (..), + buildEncoder, +) where + +import Control.Monad (when) +import Control.Monad.IO.Class (MonadIO (..)) +import Data.Bits (shiftL, (.|.)) +import Data.Int (Int32, Int64) +import Data.IORef (newIORef, readIORef, writeIORef) +import qualified Data.Text as T +import qualified Data.Text.Array as TA +import Data.Text.Internal (Text (Text)) +import Data.Time.Calendar (toModifiedJulianDay) +import Data.Time.Clock (UTCTime (UTCTime), diffTimeToPicoseconds) +import Data.Type.Equality (TestEquality (..), (:~:) (Refl)) +import qualified Data.Vector as VB +import qualified Data.Vector.Unboxed as VU +import Data.Word (Word8) +import DataFrame.IO.Parquet.Thrift +import DataFrame.IO.Parquet.Writer.PageWriter (PageWriter) +import DataFrame.IO.Utils.RandomAccess ( + putDoubleLE, + putFloatLE, + putGenerated, + putWord32LE, + putWord64LE, + putWord8, + ) +import DataFrame.Internal.Column ( + Bitmap, + Column (..), + Columnable, + bitmapTestBit, + columnTypeString, + hasElemType, + ) +import DataFrame.Internal.PackedText ( + PackedTextData (..), + offAt, + selAt, + ) +import Pinch (enum, putField) +import Type.Reflection (typeRep) + +data Encoder = Encoder + { encType :: !ThriftType + , encConverted :: !(Maybe ConvertedType) + , encLogical :: !(Maybe LogicalType) + , encWriteValue :: !(Int -> PageWriter Bool) -- Boolean for wat def levels should be (see pushDef) + , encFinishValues :: !(PageWriter ()) + } + +buildEncoder :: Column -> IO Encoder +buildEncoder col + | hasElemType @Int32 col = + pure $ scalarEncoder @Int32 (INT32 enum) Nothing Nothing (putWord32LE . fromIntegral) col + | hasElemType @Int64 col = + pure $ scalarEncoder @Int64 (INT64 enum) Nothing Nothing (putWord64LE . fromIntegral) col + | hasElemType @Float col = + pure $ scalarEncoder @Float (FLOAT enum) Nothing Nothing putFloatLE col + | hasElemType @Double col = + pure $ scalarEncoder @Double (DOUBLE enum) Nothing Nothing putDoubleLE col + | hasElemType @Bool col = boolEncoder col + | hasElemType @T.Text col = pure (textEncoder col) + | hasElemType @UTCTime col = pure (timestampEncoder col) + | otherwise = error ("writeParquet: unsupported column type " <> columnTypeString col) + +scalarEncoder :: + forall a. + (Columnable a, VU.Unbox a) => + ThriftType -> + Maybe ConvertedType -> + Maybe LogicalType -> + (a -> PageWriter ()) -> + Column -> + Encoder +scalarEncoder tt conv logical writeValue col = + Encoder tt conv logical (columnWriter @a col writeValue) (pure ()) + +columnWriter :: + forall a. + Columnable a => + Column -> + (a -> PageWriter ()) -> + Int -> + PageWriter Bool +columnWriter col writeValue = case col of + BoxedColumn bitmap (values :: VB.Vector b) -> + case testEquality (typeRep @a) (typeRep @b) of + Just Refl -> writeFrom bitmap (VB.unsafeIndex values) + Nothing -> mismatch + UnboxedColumn bitmap (values :: VU.Vector b) -> + case testEquality (typeRep @a) (typeRep @b) of + Just Refl -> writeFrom bitmap (VU.unsafeIndex values) + Nothing -> mismatch + _ -> mismatch + where + writeFrom bitmap at row + | isPresent bitmap row = writeValue (at row) >> pure True + | otherwise = pure False + mismatch = error ("writeParquet: incompatible column representation for " <> columnTypeString col) + +isPresent :: Maybe Bitmap -> Int -> Bool +isPresent Nothing _ = True +isPresent (Just bitmap) row = bitmapTestBit bitmap row + +boolEncoder :: Column -> IO Encoder +boolEncoder col = do + bitsRef <- newIORef (0 :: Word8) + countRef <- newIORef (0 :: Int) + let addBit value = do + bits <- liftIO (readIORef bitsRef) + count <- liftIO (readIORef countRef) + let bits' = if value then bits .|. ((1 :: Word8) `shiftL` count) else bits + count' = count + 1 + if count' == 8 + then putWord8 bits' >> liftIO (writeIORef bitsRef 0 >> writeIORef countRef 0) + else liftIO (writeIORef bitsRef bits' >> writeIORef countRef count') + finish = do + count <- liftIO (readIORef countRef) + when (count > 0) (liftIO (readIORef bitsRef) >>= putWord8) + liftIO (writeIORef bitsRef 0 >> writeIORef countRef 0) + pure (Encoder (BOOLEAN enum) Nothing Nothing (columnWriter @Bool col addBit) finish) + +textEncoder :: Column -> Encoder +textEncoder col = + Encoder + (BYTE_ARRAY enum) + (Just (UTF8 enum)) + (Just (LT_STRING (putField StringType))) + writePresent + (pure ()) + where + writePresent = case col of + BoxedColumn bitmap (values :: VB.Vector a) -> + case testEquality (typeRep @T.Text) (typeRep @a) of + Just Refl -> writeBoxed bitmap values + Nothing -> mismatch + PackedText bitmap packed -> writePacked bitmap packed + _ -> mismatch + writeBoxed bitmap values row + | isPresent bitmap row = writeText (VB.unsafeIndex values row) >> pure True + | otherwise = pure False + writePacked bitmap packed row + | isPresent bitmap row = do + let baseRow = maybe row (\selection -> selAt selection row) packed.ptSel + start = offAt packed.ptOffsets baseRow + end = offAt packed.ptOffsets (baseRow + 1) + writeTextSlice packed.ptBytes start (end - start) + pure True + | otherwise = pure False + mismatch = error ("writeParquet: incompatible text representation for " <> columnTypeString col) + +writeText :: T.Text -> PageWriter () +writeText (Text bytes offset count) = writeTextSlice bytes offset count + +writeTextSlice :: TA.Array -> Int -> Int -> PageWriter () +writeTextSlice bytes offset count = do + putWord32LE (fromIntegral count) + putGenerated count (TA.unsafeIndex bytes . (+ offset)) + +timestampEncoder :: Column -> Encoder +timestampEncoder col = + Encoder + (INT64 enum) + (Just (TIMESTAMP_MICROS enum)) + (Just timestampLogical) + (columnWriter @UTCTime col writeMicros) + (pure ()) + where + writeMicros t = putWord64LE (fromIntegral (utcToMicros t)) + +timestampLogical :: LogicalType +timestampLogical = + LT_TIMESTAMP + ( putField + TimestampType + { timestamp_isAdjustedToUTC = putField True + , timestamp_unit = putField (MICROS (putField MicroSeconds)) + } + ) + +utcToMicros :: UTCTime -> Int64 +utcToMicros (UTCTime day dt) = + fromIntegral + ( (toModifiedJulianDay day - 40587) * 86400 * 1000000 + + diffTimeToPicoseconds dt `div` 1000000 + ) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Metadata.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Metadata.hs new file mode 100644 index 00000000..b22dc91a --- /dev/null +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Metadata.hs @@ -0,0 +1,68 @@ +{-# LANGUAGE OverloadedStrings #-} + +module DataFrame.IO.Parquet.Writer.Metadata ( + mkSchemaElem, + rootSchemaElement, + mkDataPageHeader, + magic, +) where + +import qualified Data.ByteString as BS +import qualified Data.Text as T +import DataFrame.IO.Parquet.Thrift +import Pinch (enum, putField) + +mkDataPageHeader :: Int -> Int -> Int -> PageHeader +mkDataPageHeader rows uncompressedSize compressedSize = + PageHeader + { ph_type = putField (DATA_PAGE enum) + , ph_uncompressed_page_size = putField (fromIntegral uncompressedSize) + , ph_compressed_page_size = putField (fromIntegral compressedSize) + , ph_crc = putField Nothing + , ph_data_page_header = putField (Just dph) + , ph_index_page_header = putField Nothing + , ph_dictionary_page_header = putField Nothing + , ph_data_page_header_v2 = putField Nothing + } + where + dph = + DataPageHeader + { dph_num_values = putField (fromIntegral rows) + , dph_encoding = putField (PLAIN enum) + , dph_definition_level_encoding = putField (RLE enum) + , dph_repetition_level_encoding = putField (RLE enum) + , dph_statistics = putField Nothing + } + +mkSchemaElem :: T.Text -> ThriftType -> Bool -> Maybe ConvertedType -> Maybe LogicalType -> SchemaElement +mkSchemaElem elementName elementType nullable converted logical = + SchemaElement + { schematype = putField (Just elementType) + , type_length = putField Nothing + , repetition_type = putField (Just (if nullable then OPTIONAL enum else REQUIRED enum)) + , name = putField elementName + , num_children = putField Nothing + , converted_type = putField converted + , scale = putField Nothing + , precision = putField Nothing + , field_id = putField Nothing + , logicalType = putField logical + } + +rootSchemaElement :: Int -> SchemaElement +rootSchemaElement count = + SchemaElement + { schematype = putField Nothing + , type_length = putField Nothing + , repetition_type = putField Nothing + , name = putField "schema" + , num_children = putField (Just (fromIntegral count)) + , converted_type = putField Nothing + , scale = putField Nothing + , precision = putField Nothing + , field_id = putField Nothing + , logicalType = putField Nothing + } + +magic :: BS.ByteString +magic = "PAR1" diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Options.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Options.hs new file mode 100644 index 00000000..40d730e6 --- /dev/null +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Options.hs @@ -0,0 +1,32 @@ +module DataFrame.IO.Parquet.Writer.Options ( + ParquetWriteOptions (..), + WriterStrategy (..), + defaultParquetWriteOptions, +) where + +import DataFrame.IO.Parquet.Thrift +import Pinch (enum) + +data WriterStrategy = InMemory | TwoPass + deriving (Eq, Show) + +data ParquetWriteOptions = ParquetWriteOptions + { pageSize :: !Int + , rowGroupSize :: !Int + , batchRows :: !Int + , subBatchRows :: !Int + , compressionCodec :: !CompressionCodec + , strategy :: !WriterStrategy + } + deriving (Eq, Show) + +defaultParquetWriteOptions :: ParquetWriteOptions +defaultParquetWriteOptions = + ParquetWriteOptions + { pageSize = 1048576 + , rowGroupSize = 134217728 + , batchRows = 8192 + , subBatchRows = 2048 + , compressionCodec = SNAPPY enum + , strategy = InMemory + } diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/PageWriter.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/PageWriter.hs new file mode 100644 index 00000000..66eaab64 --- /dev/null +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/PageWriter.hs @@ -0,0 +1,115 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedRecordDot #-} +{-# LANGUAGE TypeFamilies #-} + +module DataFrame.IO.Parquet.Writer.PageWriter ( + PageWriter (..), + PageState (..), + newPageState, + askPage, + recordDef, + bumpRows, + pageRows, + assemblePageBody, + resetPage, +) where + +import Control.Monad.IO.Class (MonadIO (..)) +import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef) +import DataFrame.IO.Parquet.Writer.DefLevels ( + DefLevels (..), + flushDef, + newDefLevels, + pushDef, + ) +import DataFrame.IO.Utils.RandomAccess ( + HasBuffer (..), + MemoryBuffer, + ReaderIO (runReaderIO), + Sink (..), + bufferResidency, + copyBuffer, + mallocBuffer, + onBuffer, + putWord32LE, + resetPosition, + ) + +data PageState = PageState + { psValues :: !MemoryBuffer + , psScratch :: !MemoryBuffer + , psDefs :: !DefLevels + , psRows :: !(IORef Int) + , psNullable :: !Bool + } + +newPageState :: Int -> Bool -> IO PageState +newPageState cap nullable = do + values <- mallocBuffer cap + scratch <- mallocBuffer cap + defs <- newDefLevels + rows <- newIORef 0 + pure + PageState + { psValues = values + , psScratch = scratch + , psDefs = defs + , psRows = rows + , psNullable = nullable + } + +newtype PageWriter a = PageWriter {runPageWriter :: PageState -> IO a} + +instance Functor PageWriter where + fmap f (PageWriter g) = PageWriter (fmap f . g) + +instance Applicative PageWriter where + pure x = PageWriter (const (pure x)) + PageWriter f <*> PageWriter g = PageWriter (\s -> f s <*> g s) + +instance Monad PageWriter where + PageWriter m >>= k = PageWriter (\s -> m s >>= \a -> runPageWriter (k a) s) + +instance MonadIO PageWriter where + liftIO io = PageWriter (const io) + +instance HasBuffer PageWriter where + type Buffer PageWriter = MemoryBuffer + askBuffer = PageWriter (pure . psValues) + residency = PageWriter (bufferResidency . psValues) + writeBytes bytes = PageWriter (\ps -> runReaderIO (writeBytes bytes) (psValues ps)) + flushTo sink = PageWriter (\ps -> runReaderIO (flushTo sink) (psValues ps)) + +askPage :: PageWriter PageState +askPage = PageWriter pure + +recordDef :: Bool -> PageWriter () +recordDef present = PageWriter (\ps -> pushDef (psDefs ps) (if present then 1 else 0)) + +bumpRows :: PageWriter () +bumpRows = PageWriter (\ps -> modifyIORef' (psRows ps) (+ 1)) + +pageRows :: PageWriter Int +pageRows = PageWriter (readIORef . psRows) + +assemblePageBody :: Bool -> PageWriter MemoryBuffer +assemblePageBody nullable = do + ps <- askPage + if not nullable + then pure (psValues ps) + else do + liftIO (flushDef (psDefs ps)) + liftIO (resetPosition (psScratch ps)) + defSize <- liftIO (bufferResidency ps.psDefs.dlBuf) + onBuffer (psScratch ps) $ do + putWord32LE (fromIntegral defSize) + copyBuffer ps.psDefs.dlBuf + copyBuffer (psValues ps) + pure (psScratch ps) + +resetPage :: PageWriter () +resetPage = PageWriter $ \ps -> do + resetPosition (psValues ps) + resetPosition ps.psDefs.dlBuf + resetPosition (psScratch ps) + writeIORef (psRows ps) 0 diff --git a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs index afd51b30..9d9e562b 100644 --- a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs +++ b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs @@ -1,6 +1,7 @@ {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE ConstraintKinds #-} module DataFrame.IO.Utils.RandomAccess ( uncurry3, @@ -20,15 +21,40 @@ module DataFrame.IO.Utils.RandomAccess ( BufferHandle, withFileBuffer, appendByteString, + appendGeneratedBytes, appendByteStringHandle, + writeWord8, + writeWord32LE, + writeWord64LE, + writeFloatLE, + writeDoubleLE, + bufferResidency, + bufferToByteString, + copyBufferInto, + resetPosition, + flushBufferToFile, + writeByteStringToFile, + MemoryWriter, + onBuffer, + putWord8, + putWord32LE, + putWord64LE, + putFloatLE, + putDoubleLE, + putByteString, + putGenerated, + copyBuffer, ) where import Control.Monad.IO.Class (MonadIO (..)) -import Data.ByteString.Internal (ByteString (PS)) +import Data.ByteString.Internal (ByteString (PS), create) import qualified Data.Foldable as Foldable import qualified Data.ByteString.Unsafe as BU +import qualified Data.ByteString as BS import qualified Data.Vector.Storable as VS -import Data.Word (Word8) +import Data.Word (Word8, Word32, Word64) +import Data.Bits (shiftR) +import GHC.Float (castFloatToWord32, castDoubleToWord64) import DataFrame.IO.Parquet.Seeking ( FileBufferedOrSeekable, fGet, @@ -261,7 +287,7 @@ instance HasBuffer (ReaderIO MemoryBuffer) where ensureCapacity :: MemoryBuffer -> Int -> IO (MutableByteArray RealWorld) ensureCapacity buffer needed = do array <- readIORef buffer.arrayRef - maxSize <- getSizeofMutableByteArray array -- ensure sequencing in the presence of resizing + maxSize <- getSizeofMutableByteArray array if needed <= maxSize then pure array else do @@ -281,6 +307,116 @@ appendByteString buffer bs = copyBytes (dst `plusPtr` position) (castPtr source) len writeIORef buffer.positionRef (position + len) +writeWord8 :: MemoryBuffer -> Word8 -> IO () +writeWord8 buffer b = do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + 1) + writeByteArray array position b + writeIORef buffer.positionRef (position + 1) + +writeWord32LE :: MemoryBuffer -> Word32 -> IO () +writeWord32LE buffer w = do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + 4) + writeByteArray array position (fromIntegral w :: Word8) + writeByteArray array (position + 1) (fromIntegral (w `shiftR` 8) :: Word8) + writeByteArray array (position + 2) (fromIntegral (w `shiftR` 16) :: Word8) + writeByteArray array (position + 3) (fromIntegral (w `shiftR` 24) :: Word8) + writeIORef buffer.positionRef (position + 4) + +writeWord64LE :: MemoryBuffer -> Word64 -> IO () +writeWord64LE buffer w = do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + 8) + writeByteArray array position (fromIntegral w :: Word8) + writeByteArray array (position + 1) (fromIntegral (w `shiftR` 8) :: Word8) + writeByteArray array (position + 2) (fromIntegral (w `shiftR` 16) :: Word8) + writeByteArray array (position + 3) (fromIntegral (w `shiftR` 24) :: Word8) + writeByteArray array (position + 4) (fromIntegral (w `shiftR` 32) :: Word8) + writeByteArray array (position + 5) (fromIntegral (w `shiftR` 40) :: Word8) + writeByteArray array (position + 6) (fromIntegral (w `shiftR` 48) :: Word8) + writeByteArray array (position + 7) (fromIntegral (w `shiftR` 56) :: Word8) + writeIORef buffer.positionRef (position + 8) + +writeFloatLE :: MemoryBuffer -> Float -> IO () +writeFloatLE buffer = writeWord32LE buffer . castFloatToWord32 + +writeDoubleLE :: MemoryBuffer -> Double -> IO () +writeDoubleLE buffer = writeWord64LE buffer . castDoubleToWord64 + +copyBufferInto :: MemoryBuffer -> MemoryBuffer -> IO () +copyBufferInto destination source = do + sourceArray <- readIORef source.arrayRef + sourcePosition <- readIORef source.positionRef + destinationPosition <- readIORef destination.positionRef + destinationArray <- ensureCapacity destination (destinationPosition + sourcePosition) + copyMutableByteArray destinationArray destinationPosition sourceArray 0 sourcePosition + writeIORef destination.positionRef (destinationPosition + sourcePosition) + +bufferToByteString :: MemoryBuffer -> IO ByteString +bufferToByteString buffer = do + array <- readIORef buffer.arrayRef + position <- readIORef buffer.positionRef + create position $ \dst -> + withMutableByteArrayContents array $ \src -> + copyBytes dst (castPtr src) position + +bufferResidency :: MemoryBuffer -> IO Int +bufferResidency buffer = readIORef buffer.positionRef + +resetPosition :: MemoryBuffer -> IO () +resetPosition buffer = writeIORef buffer.positionRef 0 + +appendGeneratedBytes :: MemoryBuffer -> Int -> (Int -> Word8) -> IO () +appendGeneratedBytes buffer count at + | count < 0 = ioError $ userError "appendGeneratedBytes: negative length" + | otherwise = do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + count) + let go i + | i >= count = pure () + | otherwise = writeByteArray array (position + i) (at i) >> go (i + 1) + go 0 + writeIORef buffer.positionRef (position + count) + +flushBufferToFile :: WritableBinaryHandle -> MemoryBuffer -> IO () +flushBufferToFile handle = runReaderIO (flushTo (FileSink handle)) + +writeByteStringToFile :: WritableBinaryHandle -> ByteString -> IO () +writeByteStringToFile handle bs = do + buffer <- mallocBuffer (max 1 (BS.length bs)) + appendByteString buffer bs + flushBufferToFile handle buffer + +type MemoryWriter m = (HasBuffer m, Buffer m ~ MemoryBuffer, MonadIO m) + +onBuffer :: MonadIO m => MemoryBuffer -> ReaderIO MemoryBuffer a -> m a +onBuffer buffer action = liftIO (runReaderIO action buffer) + +putWord8 :: MemoryWriter m => Word8 -> m () +putWord8 value = askBuffer >>= \buffer -> liftIO (writeWord8 buffer value) + +putWord32LE :: MemoryWriter m => Word32 -> m () +putWord32LE value = askBuffer >>= \buffer -> liftIO (writeWord32LE buffer value) + +putWord64LE :: MemoryWriter m => Word64 -> m () +putWord64LE value = askBuffer >>= \buffer -> liftIO (writeWord64LE buffer value) + +putFloatLE :: MemoryWriter m => Float -> m () +putFloatLE value = askBuffer >>= \buffer -> liftIO (writeFloatLE buffer value) + +putDoubleLE :: MemoryWriter m => Double -> m () +putDoubleLE value = askBuffer >>= \buffer -> liftIO (writeDoubleLE buffer value) + +putByteString :: MemoryWriter m => ByteString -> m () +putByteString bytes = askBuffer >>= \buffer -> liftIO (appendByteString buffer bytes) + +putGenerated :: MemoryWriter m => Int -> (Int -> Word8) -> m () +putGenerated count at = askBuffer >>= \buffer -> liftIO (appendGeneratedBytes buffer count at) + +copyBuffer :: MemoryWriter m => MemoryBuffer -> m () +copyBuffer source = askBuffer >>= \destination -> liftIO (copyBufferInto destination source) + data BufferHandle = BufferHandle { bufferPath :: !FilePath , bufferHandle :: !WritableBinaryHandle diff --git a/dataframe-parquet/tests/Main.hs b/dataframe-parquet/tests/Main.hs index 4591ee48..398de7b0 100644 --- a/dataframe-parquet/tests/Main.hs +++ b/dataframe-parquet/tests/Main.hs @@ -15,6 +15,13 @@ import System.IO.Temp (withSystemTempDirectory) import Test.HUnit import DataFrame.IO.Utils.RandomAccess +import DataFrame.IO.Parquet (readParquet) +import DataFrame.IO.Parquet.Writer ( + ParquetWriteOptions (..), + defaultParquetWriteOptions, + writeParquet, + writeParquetWithOptions, + ) withTempFileBuffer :: FilePath -> String -> (BufferHandle -> IO a) -> IO a withTempFileBuffer dir name = withFileBuffer (dir name) @@ -236,6 +243,32 @@ memFlushLargePayload = TestCase $ contents <- BS.readFile outPath assertEqual "large payload round-trips" (BS.pack payload) contents +writerRoundTrip :: String -> FilePath -> Test +writerRoundTrip label path = TestCase $ + withSystemTempDirectory "dfpq-writer" $ \dir -> do + df <- readParquet path + let out = dir "out.parquet" + writeParquet out df + df' <- readParquet out + assertEqual label df df' + +writerRoundTripTiny :: String -> FilePath -> Test +writerRoundTripTiny label path = TestCase $ + withSystemTempDirectory "dfpq-writer" $ \dir -> do + df <- readParquet path + let out = dir "out.parquet" + writeParquetWithOptions tinyWriteOpts out df + df' <- readParquet out + assertEqual label df df' + +tinyWriteOpts :: ParquetWriteOptions +tinyWriteOpts = + defaultParquetWriteOptions + { pageSize = 64 + , rowGroupSize = 512 + , batchRows = 4 + } + tests :: Test tests = TestList @@ -252,6 +285,15 @@ tests = , TestLabel "memory buffer: flush empties for reuse" memFlushEmptiesForReuse , TestLabel "memory buffer: self-flush is a no-op" memSelfFlushIsNoop , TestLabel "memory buffer: flush large payload" memFlushLargePayload + , TestLabel "writer roundtrip: alltypes_plain" (writerRoundTrip "alltypes_plain" "tests/data/alltypes_plain.parquet") + , TestLabel "writer roundtrip: alltypes_plain.snappy" (writerRoundTrip "alltypes_plain.snappy" "tests/data/alltypes_plain.snappy.parquet") + , TestLabel "writer roundtrip: alltypes_dictionary" (writerRoundTrip "alltypes_dictionary" "tests/data/alltypes_dictionary.parquet") + , TestLabel "writer roundtrip: alltypes_tiny_pages" (writerRoundTrip "alltypes_tiny_pages" "tests/data/alltypes_tiny_pages.parquet") + , TestLabel "writer roundtrip: transactions" (writerRoundTrip "transactions" "tests/data/transactions.parquet") + , TestLabel "writer roundtrip: mtcars" (writerRoundTrip "mtcars" "tests/data/mtcars.parquet") + , TestLabel "writer roundtrip: int32_decimal" (writerRoundTrip "int32_decimal" "tests/data/int32_decimal.parquet") + , TestLabel "writer roundtrip: int64_decimal" (writerRoundTrip "int64_decimal" "tests/data/int64_decimal.parquet") + , TestLabel "writer roundtrip: alltypes_plain multi-page" (writerRoundTripTiny "alltypes_plain multi-page" "tests/data/alltypes_plain.parquet") ] main :: IO () diff --git a/dataframe-parquet/tests/data/alltypes_dictionary.parquet b/dataframe-parquet/tests/data/alltypes_dictionary.parquet new file mode 100644 index 0000000000000000000000000000000000000000..e6da6ab7bd81008c1686f04a3186265291aacac6 GIT binary patch literal 1698 zcmb7_ziSg=7{}jSu9wR-jivNC61d?8`%uBwyQC?}q#ZPES z$)SiiIu~(pauksc4k8W}1i`_h@zC_l060`z?6jUa){m_SA`kw^;YX&~fFlF0Ho!LI{TMRi}@#{$wWa?3)N zyRk7h{F=~!MP9PqYz7BTGwAsms)1*8B>Bu0#vfrA$70(LANyf@0ObLF5by;>Juml_ zIOJwp4J!yH!M~IEDLFs*`@#^MnqQ>u5Xtj_1@NmHQR1g0u z_?dD`IbVgtB>46O}CW>ix));$mr@O?@p!XTa zyvu4JMVDA=`wh3%wi-u+E1ak&_#}((i9~|DxGS}shi=0Hm42=XT$==^mx0{+_ED|$ zEn{8YH(mB8C5H$j9mPN|_^D15`PdJHmTn9O#_v7#wXBM4z2-NLtiaMAxyLQP8S3U{ansni zSG#|EueMXw*X@oU)b+BpRj$`<)AhXaR;g03i=Ja_+s?LEGF`_k7_RAgo>RHTzR~F3 HrGKY?14}7} literal 0 HcmV?d00001 diff --git a/dataframe-parquet/tests/data/alltypes_plain.parquet b/dataframe-parquet/tests/data/alltypes_plain.parquet new file mode 100644 index 0000000000000000000000000000000000000000..a63f5dca7c3821909748f34752966a0d7e08d47f GIT binary patch literal 1851 zcmb7F&ui0g6#pje+AM2l7<*qVv_zM;YQD;X!1G_`Ye@W}TbqmweOL$NPTX=lg!8G{0y<66Rp8 z2pS|Aqlfj;PSH-&mT4zwizU$p1|0Y`VGJoyS_YbwNId;`jBg|z+DN8!b`S=|Sr$Ee51+|8u<)E>*X#arx$Xz24Q}8O`6X`}Xhr%F1Zjm_hG6In z7b&rg?-Cs*15K~?$g4Hmpi6uSk7fKqck31Rd$NO@X;dxW?*`sZ;$!02EAVEj1Dx*0 z{MLuNloZ0uLp~A&5eQYhXi-eh3&y9k4#_aQs_m^t;cL8xuhMu#`94GW^Wlpd7r_2h zbWlRre%G&Crz3oz;A@fee~~T(>&n~(=)0;8>IvyeeZ%&hb^-l_Dsj zFvuM<3gd=3Zp;SqWJI2b$YdaF$o()3pD7?YQ98!mj1HO5|D}r6be0>LFTr05hN163Gw zN`^s(6x}&&X(N%RnM7u%??nSFr{~`PZ?MG}V6i6>#vU;kXJ%l`=Er#5j4|7?$M(UP z-GIH6Am3BD#zq&s>YC+S`G?MW!>iZw=2&6OxPE8h?#;!8`C@+5-thcNe#V-dsZ?y! oaow58so4p;;FgVPyFBeqnNHc9FdWl$-SX^Jc0`|z5`8xR0vs|-V*mgE literal 0 HcmV?d00001 diff --git a/dataframe-parquet/tests/data/alltypes_plain.snappy.parquet b/dataframe-parquet/tests/data/alltypes_plain.snappy.parquet new file mode 100644 index 0000000000000000000000000000000000000000..9809d67652fd5b01c7bd8b85da27eb8f6d072b85 GIT binary patch literal 1736 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 z zrqkR4Lu!0!6t`QmBpMja0$xBSn@`*cOej-jGSgu|a2dff%VAE8ic833>=enXLqy70 zoMv=Ntg9qQR`8*nq6s5G!h})1NFgk-BC~cQB?O1LHdUvnfZ&+IQs`CTflv{hDm;Q` znGq9_5;C>yHG`unAXA6IP0=V}o2~&lF*78g%&HlNR6-`l39Z3K>TJenAG>e_W)*3W zYG|6Qev39;%+5>*Zu)nthG8R%`VK2InpVQSz;xlL?GiG@GVWoatP^%ddiR1sdbnecUCK$yk>zQvRH(YK#MU z1!g+LI8a_>KP75s70~k9$VW)XWDDyR8ZEY2Du(P#)?X%#z;tLxI%z~2Psn5hS0#k> zr0JM6qWLMI0w@yFXCZ-;Wdv8#+_G4#w_%VE?GzBaQF$|6gpEvQ&wCi{dkBRvIVYE<5gm#qECjN+_4v%W|c%>@aW>|8Kw?|pNtf?zKwGI6}o zdZedBFED!AeJ)4{Zc7`y_dSzSeb63P1Ix%2nUD#Yj7fKcR4~xMQZKI6(Q2Mhfm_Y9 z_`wN#JRy_hbgh|J0y0U>$`zP0{nMeTomyFeVQ%b#@ls--INQVHLn~!v56uUW!vle{L7=#* zEjuRKE-CnV-y>MDr_M%|!N$iicAQE>^9bP>Ae(fpK< zsZmku!Uts1USP&T)KwD2p{pP^7#dP6tz*oWO2`B^34a1I6*1)rEvj8YCTWj^3Nn!x zj4ni1$v#>jS3vLst+5hQK|&^}7DqxhnKVWf>%IwP>O7a0DL7jstJO5D9Rb0s8sm4j z2_qr6Yn52^KOqywUDgIREhlqpA^KAx2B#__P&*g0&oY8JX9RMxD@%MXNa%^JE1jV* z%SNn^CzL6DoFxWiibz>Uu(nIKPKRx-XzEGG>^vFx4 zPPq2SRC?pF7hO%9`s=jwM_Ih|hjS)b&Tj>0-f8js`?h(&a@vh<^`ym}C!O-L;?3l4Kkb$y4k1o`vZm5e77t(F_*l!S zf7pR7EpA@B+&PxB;jMiy5qy%An`)9@;!5K5k-z>u+~UPo?wnvbuZ;ZZ4vUA@`tCl< zxqHbc2g)->$Eo~_SFD$Z0)SIzUi`to1Rt;YFIe#Q3B>7XV_(zv>goM-#lK(ZVmXcW zf95iaH$S!fTFa^T$D=n|yzl!;C2BIN&KN!`jy_JKW;VEiSD~oE~)U1;<%@GuCjeBf5qo(EUxl3d5XnvoOz5s{iwf9eR5?ZdDO<>F*Tc4w49BjPb?$2sph}# z+7^F`&K0M+6>YWM;?YO7`J6c2^s6)9wRqg|=l$Dq`qk;O+~UoTUb4t?etW0)Y>VgK z)c+RCc_x4ANWp09AJ-2WK%5@Ydq{VS=iEF(BE5#wp-uWUi|5ZB+thN#O}<&*5`uin zy<4$ZJ9(T_<-fVh5j)N>I6Y@r&95y!wV>Wd#Hsm{>c1(tzVh!F-S9c$bd~*@JYsQ+ zhR4shoZo*qX}ZNFhV&X|@jmS@7;5p4mCo;L@l&66zR==scXVrS@yY}CpJ3bKmBY&3 zVezNaYuqPT*PYa{pHzO#;{Jymy2|1S?T%POoIY#N_Kz(ta@f|bmeclg=mecFe z7yc2Qqp$AU_w^r%Q~h3Ey-9F^*5#5FYt|8`x3+rsReOKb2|uo|_{CZuKWK5eQJ>#s z`M+Gb`Bsas`t{qSMV9C!__6nTu7Ym zIDLG3iwCc``DDwfH)KkI#n-)cdkxF!_RO6JS^RO!`K2u9yB-hhD#QG5ul(q@#Oa@g zEc;AwTdnWre#`$uoVt4FiWe=u?Xc$_x12T~zqrui84X^WX*t(bS#y)cm*4p0tbJ3d zv$QT34Ow|Jae7PMbJts3s`iUlSuUu;JekZ(np5^>>^t-JrzH-@zO)O{B+6}cW zKI)b&hg!~;kv3JF%IovPj~3UM^vfpV^v7*~UuSXcd4IoZIo*f< zYo*1lPe1x0i}x>c%-t40`C_9f7T4G?z~Wv_ z?>^IV26tX?lEsG~u%vVjLTP6l*)OdsV}oZj{A6D2J^wEW87#f}1J?c2|7 zv-tClUw&%wS9L#n+v3%~uY1Ab+n)JgnZ?IlyI}!wdd7b?&JYY+?&g1gDbH8|rw%&j z>k$?=8T{>)mb1O#kC)i{zuo`db+&JpdhO%>7C(~S)Xnzo{XY5XT)}O$UY%AR*vjG$ zR{YqQIK5=_54G(5UMKxp$>L?V?<#9KGsdL;kt4pEPr7{Bl0OosYSi6tlf`d-nZM3* zYM)&3Rl%*b+;1+rGJ* zv~Jx-u;!UQY0AmxTYOlfLst-|7Ogt`L5pAis@h!^*Bj8Rm&MDzE*M0dUh{k1(H5ug zJ?d7=nfRZRJDe%=%wQ?&@0KTYx46vN#|J zs`o9f`S=xIT71IMV}BB?I#1m`@cNY07kJIm5&K#E^%tWm6Q`G!KC6z!mo975)N&U8 zdg^HwH$Ui`&&38iOY{7wN{R1@(^GnWq4uQSPtO^!LG4U%PRjpO?a#B7KjXr6YL_Zb zk8Qq2?N#71PrRyjEI2h^c|q-4;192QM(tj3syz0j+QY#A+_FsVWN@xcJ)-s%@aVEj z)V>0z`lE~0zB)(C8nJV}+EUW@hUmfwG`Xj(=$FEaA1)Ra3wveBu+H3x6Y8m|~i<_Nu<2uXf(Ea*N7MCbH^he7%tNoyVEUs7ks(srs&wJeGLfYAyb+ z*0JJ|wGOiQobEMiSlsXSIt3Q@ey-8U7I$g7e|zHeyy0aov^d?dL|@C<@mkTL78hOj z&sfX3Z`fbc1fQaH{BzZ>^N3U1?%BT7;sJ+z{jBBOT<43`7N79mC+jU|+(931vAEZd zYk#$zk9z(mPwI~RHxGF00E>ryp4Z|GS@*TRhmI|Bmf+JB4|?-=^-~n5FMa4I^-mLx(@Z%dkS3d}xKdXMC{t@uUe}15T6FBD#cu)N);K~pEsD2hW|5SNf z{V&Xi=l6M4{W8U=(^{`me+_u`suk+TfwTFYWonZH&m8fv+UnqJeD(pg;el7pT%fi+ zI7=7xscqZ$+%tPO5!_1iUp)QdR>bL<*PMTz#fN=&_NA87?C-W$S-kE3R@Ylj!*5Q$ z+1|gX+9|Uv{-j#Z`z@Y+YU3v?zwy?FF9}vXrzh91`wnsH`f^8Zu(;^N!@skf!)90f z!{WDF9#~rRfHtZ)Zoh*q{;gY?BZ<>jyjP;3yKn513$%{3w-u*phXd0-2Zwe$FztD8 zXy*gd{s)JC!G)SX{RPEo`VqkNFTkPS0Ze}c9QrB1^k2ZCUjs~k2ORoA7iqZ%)-QUD z#EZIUx%FE7sc}W%&%fEBaYe7C7|3fa#ZkLw^mJejGUT?||v| zfkQtRZA$+an0~S1H2q~@`qALfzXqn?4G#TrVEXCc(0{*J>quWoahg69Fnud<=yL(n z7Xybr8Zdn~aOl$k)7Jy04+x!)ZFyu-iJ70LGOQU}rJTh}x>Y`$I9=_%igoS%VJ94V zoW<{qIk2_h4*J{LZ|>8HIDPO##d=yir^es9cLUB-$L`d<8{obhe$u@g#p%bYZr8mV z;HiIZ)x8^VN)FhpdpE!bUbjj2Zoqk>%0}J0>8QWmx6gXryHT9_^YnLh?*@4Es@1x8 z1I{!1-1LpLk&3q;yVK$?YmF`;aVOYR%^sgrfjHf3(eP^aeyKBu))#!P=HG4lfD?(+ z?XS7KoyBXv?cUXLzW?jOJ{Hfr|NOz0vvupaV=R8{-|cU+oD-X#agW7qPi?Jlr`593 z-+tZd8Nu2v=~L^U_y%#RVYwzBTKv?6`kO6h?W{V#Slp}hrPYh4Qs?V$4_hv^Z#2(x z+AgTK)Z#ze*M8RG6+KQ`ZTZ!&sIuPPpS7X#7K;~bJLFf(Iep5(d3HXW`@#MP*!ghs zlckQZ_vgIxvhK&BecO*%rTcMc_ZiPVt^0AngJ(Xe`*GlG+vjoJj|2Yg;74^o4xHJ~ zJgob1owSZW<=wCQall8nSTMrkR@Xc*(c+WuS~kPth6hhxK%5@YW#Tf6yEYp8g5~_@ z{?Tt+{K>MBpIXky&ev@dj5d0I?lr#?r(Pa)Wl7P4-cPmN**l*&O`mkA+_%1};uL*Y zVEVS;&@Tq2zYGrj=pp)B`qzrB-;Mk9$ARgm1Ji#8pYa@E#&f`7JO`NZ9B>%V8LVY7 zo&!u9V7SUd+W?p`4aI4$LBL#_z~P!TOn=L@447-2VjI)IeXfbXTr0uh8Vbyq1~^=E zfw>lg!ITn_EaxEwI! za^Ns72h6w}IE>2yGcE@X<8s$({*22hwsARN#^u0aTn?CVIbg=+fEkwqW?T-KaXG~{ zE(hIme*`uI)0Kldt2aeZwr`vTfp4g0_NTpF!#2AX}?{k^`c#;I8A#GnD#t4 zwDU(OpZ33EyRQLEe*ql&5y12>z@gs(On(F%`YFKlU%;VXgSn3W4(2-gLBRBnz@guS z`?Sf?zO>bWY3~8k4!mCLMf*^(jrjx9o&<+>CNS+!aA=nT(_RIKb}TUMTX1OiLeI2^ zp=a93z_g#ipISxu*;c_mnTwvbd)V%yT&XR34ts0p__K#r8Z8Fwgmb z!}C9V^|w41q&UTWRbcM7g2R1SVD8U?!+l#|?&kt?pBI?>zrfrVhHklE4BhfP05H!3 zfWz|%JykZIPf%>nC*VHMCjj$&0ysRM0L=3V;P89`FwZA|!}AF}G=J_XE4F*ez}!;? zhkMGv+*1aJd&eFwe4r!!xeHJo^d`eTv&ue(oJBmP0hyQ=X=nHZ3r1UB!0) z{dVQhUjwGUrZ`Q1ZMtImYl`juJ235haA@ZP)6NHnc0Mred~j&z1Jlk2hju<>rkxL& zx&IDKJ0Bd{`M|X8fobyt(+-6FLmM8LwjwZXNMPEQz_d9Pr)i4<(?$h{wkt4gT5xFV zj?&-K23Bm(eF4*E28XsZFl}sbXnO=<66LsYk|YK z7UnU=wJ?wIJPGbIt_94v7BJ&l;4`iTO#cpjhW;He{X5fV%(*pS`gh>azXPU!2M+za znW`W9cZ%(~HDLO8;LyJVW~>Yx#>#*hD+7nIGGNBaz+tQm?ZsFbFk@wCJMI(VK4WFT zT(fRgnYotTqL^!(V*A_#FxNzIxK;vl4F!j5D=^nwaJUu&bBzXvYxg8Ai)*@K``iRD zZ8>mg;{nt51BW&tFl|L}XhQ%Xd~Knz_jmy^TSO*zv#eMo6lT{x2BZ}>_55V+az@c9QOn(O)`a!_-kAUen zflq%5_vvTds%6oJS8Shs0H)0k4t)V&`Uv3AcL1hO0S-v?$K0GNIy`1Ch#)com(Do)Wq1*YE$4*gkR`nllH{{`mr z7vRue2Bsek4*lz~S{D6o#rC-m+-JNutsKUC729|(Fyp=8Fy0HycrP&Hz2Gz6i~EfC z0yEwV4r2?WwJgRQ6sH)A0A`E=9L6qy8PfoVu?}FKs|Sa%5n#qlz+o%}ZNwN0Fyn9I zA&+_11DG)%#VN*ufEgnKX6$H!au`zrW~>RAF(}3M*#Th2tiWL`3z#u3a2WdnW=sqm z#>y~n^O-oz+l;LNGv)>kV{yRrE5V_^3Cy?+`Uc}Uz>M>t|1kao%(xIZjQasI9taNO zgyXdCj2|jaF|G*Acq2HBLjp5C2@d0yz0hsF}FxO4+xqcxZu3yNX z>lZL>7;tFAT%={uhEZ&77+~5k;LwHvrVRrQZ5UwMFyL_A>5lx(GkuCvT&IA!egU&T zcTo=evtk>|1!jK+hyA&$^4Xsir`ex@*`L8-e+FiM28aC_n05|0v~z%I=YT^y2Rf&n zgSKEi2bgvaFzp;*u3z0$PR6Eyxqc~5as2}3`UMWxFJP`;;Bfr{=K2K=*RM;pEUsUQ z?Q@{Ov|+$u><*YV3^=r5aG$mZ+LZgDXgk^}z_ek2Y1`npw0V#xZ6VyJt&2WGTNiza zw(i9$Gi_bP*4724tqTrqU0~X};Lz3urmYJOZCzm6y5P{xxkSsNouk;=Il#1Yz@eQ3 zOgjf0+Bv|qbAV~*fKNLI_i5*#?Rb`;x5_|YOtF1-0GPfTFnv1k>FeP>eL!IPhTza= zyiD__FR3_19}}3qCph#;f$6J)Lmw8H`~Kk2=LM!O3=VzdK3W#f6)3i6`GDzbgF_$O z+&9mL^w8fjzSmPR<9oo2?*TIo2Fy4ZFk@wk(~O}3Gqwf}V{X8V#eu^ZT`&DDV|R*E zjOhV0)&~w_fWV9mg2R|0FwYEt!x$qlV~^l4CW&@rtP+?poW3dpV>^n|jQR9a%)MR3 zHbw-@*bz94`2aKK0}f+8z>N8T!c;)9(O> z{s=Jr6maOjV4k6015BSCeTKd~`VxJ7VEX>Rj0u3xSi$8gCu0bTQ;aPDGv)veV-diN zQGmnP1u*xl!C|Zem@yD=7#l%5GG>ByWGn@kd+6XW_5#e93^X@`SDJN#Vb(+*c`?Qme);o#5?2c{hk4()JY+Tq~P4hNg3HV(|#I5>=r12Z-b4rAlMjE#dsKMw6n zKMw87b34HF`D4!!1Tp{>5D10z8EllF>vV1qc72yM}MO) z-$8#%UtY2G<$>wTgF{~)n7%wX^yPu+%Y#E-9+AQhL-wo|c-wl}do8aT99foaErL;LnR{VnZY z#cA5Zz_gRWq5TX@yBZwY+rYHL!J&N)OnVF*+G!)ST-tBIwCjLr?8|EXlLR+ZJV^_!#&MWin+Ebw&y2-xweACy)9s_t>AEN1?Jic4%b#-uC3s3Z5^p) zacxy>&rbp~#t065I$-*A;Lu+HroR9V{RLq93&8Xj(C+jX(C+jXfa#9_)9)Uta?4NSiqn0_}f{X1a#eZcew729|?F#Shx=vNNb zvgmIrPSXzsrhf_!{Z?T5v*6Ir1*ZQC4*g=>r(c6UMt^6xmPn`cuI4 zv%sPM11 z;~r>t#zWBVjFSK}ege!rzA-8%{Sn0}`Xj)!Gr^&qiTkuOfoW#~)6N8+cIH^kpLV8V zYi9z}&IE^cCNS+xaA;=&)6N8kb|x_GOmJvtj?=PeXDYVOhyl~i1c!DeFzrlmXlLR+ z{hiU8C;cGB);|KK-vkc*Da>#5b8k>S{anS?&jqHR3rs&3eEPY#Pd^u!el9rlb8poA z>E|l8el9TmTyW^;0@Ke0hkhuj#Y)ic@3Wdr6H{r|&BOr~jBc_bo$QrQRKf5~uqNm|EN7)6baH#B!c}Zlb;? z0l$6yjdAB$OdlETNZ%QlKDFW$eQjX+;K20Ff$6gY)0bCl&m^Fa(a!~@pNoD-KX<(H z>E|l8el9TmTyW^;0@Ke0hkhYP9Ioe#i^ChyYsIiPRQ&c8xsqn&@HV%qtN?S2g~?R;?PCj!$?1g4(| zKKE;IpMD}R{X}r+C-&E}=qD<+ej+gaL~!UQ0@F_fhkhb3{X}r+Cj!$?1c!d&RazGP zM8(!m1g4(|4*f)6`ia1dv0y%7>;>})V=|c27^}gY#uyGTV>{q5<^#-F5IFQV5m%wV z2~2+znEobk3FB`9)8ABV{Y_x{o8Zvj1g5_U4*gB-8`j@MKjCu87TYnRn{w6r|H-YJIf^f!U&Z-T>^ z0NRVO0<-wrr@-_GsI=lgaP+xP7N^L;zOeBTZ*-?szI_p|`>JuQmUd`}B7-_rsP z-_xRPXW!F;cI10nfcc&laQL1UV7{jX9KNRonD1!;hwo_t<}=`Pv@AXYuGl^Uj{AHD z9GK65-=%y$1FqOU0}jk*z`@}&;J|za92`Ca4$No3!QnIDz06^TNP{>^jWzW z--=Uw?i-lz*#L*{*#PEyHo)O~Hh}q_4RH9L4Pd@!1Dv{bztZ>j-m7Je8T6^XzgKa3 z;Cb)s`+I?ZTm81azZaYXKYd-_-wXWmxPR;Wd%@ZF_2>2dy|~|`$P4=ZUf|_LHtG9& z!Qr##=vRF9e6E(uXU~E8>^U%>JqPBq=fHgST(N!j9GK6ZgTrUff%)t?IDGc}9{nw! zJy)FKv**Bk_8c6(PXL(j699+r69DG>1i<0@1c3QI0dV*}0kk{cCxCY6`vidbK7s$o z*LlZVQDklRCBGiJmzIEsK`mZWeDol~{o=aa@fWpsSjJS8~gDX@788uJv`JOzz;3T&Q&#ykZ!Pl3%- z#>92aQ(*HH*jzX`<-)MJFdB1V*jyNmxiD-ljHcC*Q)69dzGHt{@ItH${Y;$ymh$6c zUFhK4l^aIJy3p{7W<$TUwmoB#GAU`QAN%#-O;e7?VdmJg7eRkbB7;OuTkPr!)jfVG%r10vqfUp`|26@ z?0R3Y>wN|1UGJ+`ubU@*mcR!xGov& zx@2fvmkf4YGBmC=$9KcE=J;;7)*S3wb7)*^4tA|M*tOnoyheMQ)HGSRqB zChR(yXj~_gxY~6xiK|^F6Ly_UG_I2gyG|zTx@3GmU6%}YT{6C(u1f~HE*TovC4*gS z4vlNgJr>WzwdR7;T63^#&7pCvIoP%4(71l)Q{uDcS-~mKg3YsF^DNjrD>$tKIW9iyZx;5q9Gv$z zJ~r6jesIn{0k*F|V;=(Bx1h1lfn6gJjeQhs--X6Ljb~+F$Fs5zgk7@{jeRC;UkaOp zPm6mt2MV-7weu4N7$ zoYpmk&8^UwTXEccnf5hbrhUzqVe@4)=F3y#{H|*noYpmk&6m-bFT>``Xv~*k^JO&V z%dox*8hsU>xxNa|Twev&S3#q%0_&@waa?#l?%DoiQn39=aOzKB`x7+wC$Rkq8v7I2 z{sfKv32c9Y#{OhtT+99>IB$Of+n=DZ4}|R-(b#9guG@*mv4G#ZW5nb*zhg&m&M^gc ztU==#1iSVx8pkZyu?%*MgB|-|$HaKfImb$#xnn5o*oww67j`U0;}{LQHZmH=blCW9 zLR`zZE;w(zH$K=nFgRy?2pcz|F`k5tGtn4-!mdM$#&{Jrjzwd9OMhtGOMhrQ%yDBC z*cb*jwn1;q!*Rz=+Sl=vc6Xe8F8)Wy-{73%GVFMb#&H~Wd`IKB54%n&8e>%0*cFX2 zEzjIo7d8gw`5PN^+?W|QmPTWY4ZAKY8uO|D#Q$PG6`VJpg3YJUm`}mxQ)tYmVDl+7 z=2Nf4`OT+-Q$7WoPoXiNg3YJUm`}mxQ)tYmVDl+7=2Nf6b@f|bitFmP!1^u0X$kxAEwFhpdh=qQvw1Pk**yLA zxF7R$*gQQr<>|0_IvVqI*gPGLc{*&Kj>ekx8*wdb*1@S}4O_EDW6c`2W{t+0HEhiq zjWui7nl&2pV%pTa7&b2s&YKs*dM{}7USPc!GqX`|88 zhV``3=xM`x+GzB&VLfd$dfId1T6)^S$$4!+OrJo^xFGtXSl8P;<~qvs6kL(Yr))Q1dC z>zcy)kZANFVSPw6`jGSEv-*(1XLlB{cVoxZ^QcAX!N(~5B0a{ z5B0Y>Zk>YmwN631Tc?1nQ=qX<0qZTJ(OaGy|D)bAthWs7EyH@tu-jA`GF^Be01 z=Zt}2V`JDDhVvN1a2{irH{(3UFu^H?fsJ9%7{kEEFldZnU}G3G#xSrk3~W67Zd}WF z7&aab&KnQI#=~fghhgJk*mxK=9)^vF--&A(4+rOshhgJkG{(cQ@h}?WVc2*WjqxyS zj)TS=hc+_Dp)K@GU~?Qa<~Xo94jSXqg>lcurLZ1daPk3QeE>B201M)?`T)Vn2Y~ef z(C7oe`T%J30bqRqH2MIrJ^&hh0LE*50LE*509YRYjXnUZ4*=`8!1^$-{tT>d0~;GJ zj{niv7&bNz&KnzZ+}Id4Hb!G?3>zDxG2emBchH#cEQ<4+?*ylO2R7e9W4;5M@1QZ? zfz5Z&nD4;mJ7~;zXm|4++TDDI!~?AGXg&W1s(d=)OVIAtG+|>2G{zDfH^yEW_i2m`8)FBj`X+3Qjm8-J z>-elOc5sTZVPkAG#@MhiHX37W*ccm)F*ai92|`~IBX7%#vJ^sxRyD1aLU19 zb8s}~;IKJ38gp>i92|`~IBX7%#vGh>H+Q4m&D~&gH#FvM9M{kMFz#7DGdQoG3F~K~ z(a(hSGtua0eiWb8&kWA#XTtiKX!J8-{Y*4^)UX~k8s~|@demt2s6UQt=}`wKj~dpa zMx#d!>rtc8qlWdU(dbcgT%X8gqQu93PE2KHmp(e7*zb z_^>%X8gqP(o8x>R_i63}`#TLz`z>w=_IDbbey6a%Q#AfgVSlG+{GG!7PSN-~h5enP z@pt+|T+82SaQdCX{!U?kr?9`%;8e$1A8hUuoc3G%F4$ZtIL!xu&8^UwbHV0fXw1=I zb2l{Rbg;P|Y!1k?H8+IK8DVqD;4~iqHupqhP72%S{~Z5|eSUDNOTzZ~XzcTU3B7%O zaO(46`+PL^`LKOH8vA_MJ|B&JKJ0H3jla!}aV>wF!RfaN``bk0ZB z!;a%<%$HZi{aa5DPIY$Jyc>=AIBY%zn`fan|KhlL8En3W#yoCyT+93}IL*I;%?Ht# zC&K2BXv`~N>uYGtLpg4aOk8U244YHK=Gw41IBaenoaQyb=JIIF@nLg+G7C8`skBS{JMj7M$k5zm8xdbD(|oBG$zJs7Dc;=G4J@ z8ff%7U_B5tdLtayGlBI|U_BP}dV2Ak)0{e_H;6{h5Y|gXqsItaKSiS_$#LtF zJK}#dCkjq=N!XkSjX4o)PK3ss2sS4|V@{Ndwdu@>f>TZen-ifiCxXq1(3lg!=0s@B ziC}XgH0DIR#C7#dw#WaaX9DY)1gE+rtY?Bo&ji*pL8E8#dwf>UBsh5{u$~DTJrh{Z z1dW~v$MsBLJrh{Z1iiT}&)M9T=WKpmD*j*dYuNlcIOW%{`868zYuNl6jrlcfevQVu zyp8#E(u$gL}OhNwl0arx+H8}5{-39*t#Sdb6eWf+!i*s4Ni4QSkDBFo(ZgH zg2uWetY?Bo&t%v5AN5RvlV<|!nV``#f%Qz#=$XKJCTR3bU_BF9&xB`g4)tq%$IPK% zbEx2yLvh?33O0w@9G^9Z3Qjo`Yz~FS911pvLSqgEn?s>7hl0(a(3nGQiEEid1*aSe zHitrE&O(f2&H|gWz~(H$sV>QJbCykUesh-Kl(WF*ENIMGU~?8U<}9!|3mS74*qjB8 zISbFriR3p^`-BRn5} zJFxyd8h>lBzdZmT zEQTGU(KvR)j_GI|>tWZgKx1qG8#ACWmN-1FWsDJ=GxmUuNzfRp!20uOjBQ|J9yG>6 z95+TfGR|-86r4Avf{nG%7=yvaW@wDrVAn-KV~htI`=K!=JR+`TtQed#hJ=kR(HL{W zuIqxv7!@{lg^g)prEo0+Df{mGBW9i_$F*e7IyRf_={pjd>YtzJ|s;E}nD#{GIRd@yyNpVDmvV=83R1H8kdxu=yq$^B0brpO%UL z(Y!S{Z$1l~=b|zHh0Tl6I5!41k49sDT{_Nh-VK|N2d6m<95;W5&Fj&a@59bvK%+kZ z>l>iaPuMfAtLIQIuB8_N>rn*f^e$jM4K#WkupS5+y^*r ziF#$^iF#+SPvPE-twR0vwF_KdA(>@j~b2MHLRzNMz0&T=7>gb9M&^OqnBPjuBFEwoYQ-U z_2kj$)e|G>;lr-yKpds#4_gaBV~t?HIKQ=n;Jh^j*jfV`YY?!t2{hI$VAnZ8V~qp0 z_JPKl2+!R5IM3V~3T$l!js84rEoSeyPv-#!=d9hp)^uQNJ+L((*xJxu@mXs|!D${K zY>f$xwI|q`6dG$)u=4=XSlfb~2Z+X682zF10O=3)a$)Oew6*m$*t#3MWcsF~8Wl}_ zh!W5#n%^-Z3+ zbx+tj8ti@6_&@gg(O83n?N`y*ul_ZzWxpDn`c>F|6^;EWY`==e zeigP~MPt7T+pnUrUp+pqWxpDn`c>F|74|m>`x}J)4Z{8ggVS#i_BV*enC+OjmN8p! zirF}B%my2?p)qEIjoHu`v%$t}XpGs8j`LgZ3r_P+VaIwjj`gr(JsQV)*s&gsV?FFx zkH)e7sJND6eQ+AzGS~SMAurVzfV_Mjl z7L74&)wq^1ZE%{m51X5yF*kwDP0*N|z~&}s%uQf(6Ex-~d=Je5>&89kxxspF!O3&0 z6RhVJoIE#<>$$;tZm^ykdObH-&kc>98?5JsM$fHwTuaX_IH%_Z>$#!PbA$EV(CE3r z`m|{DJYl_1GZqmK&fqoUDAJt?lMj|%If!uqJe$w!6F7tok5z~&2R%okwu1vKUh z5kI7Sfmp(P0XAPiW4-{JFQ74BfXx@sm@mNA2>AY6BY>?D@LRA(a9aG2)(C=AjR3Yr zfW{gDJa}h~0FB-D9_lUgJ=9x<^_J1-EyH@tXsj2&)(c=g;j`lZ(i0BO>j~Ej))Nj+o-nK@j7Cow z))PjfCk*QeqtO$F^$*eLAJV7kAJV7kAHw>FX!H-^6+8VyH2S?~$NlT~1}DE4*6&55 z-wW&aqS5b#^?PCcURb{u*2Cv{>f!S|_3&Xmd^CFaupT}dJ$zUXAC2A){=w%UW>2U?8d0eob7#h7YSPu=2-WsguhQ>V^pN(_6 zCNTWS(Wf7{OVL_+anG(HJSy0=g@bdhISjiNF&g);fnB>8jceP&u5F9PwQXV7wngLG zw$H@1T-!D{t!)dtCM_D*s)b#{7L9A$!mfFX#+>&yFN4Qy3KqCT+f;Bfa^TNuK$e2b)jL`i$>!*(mVB&U*7TeTy8ke zt;ND0<(%uB4~zfH_0NOT{#hJ%y>!@h)M3|GNAJ4pu=_5dah-P9_1n?7uKUoqmg{T> z=Ujgqc3o~XuGbB_jyD?D_l8~f8;$!^`-!{|?4A_CdH1A%-ID^1 zds4veNddbj1?-*_uzOO#?#&dO_GX$K?A}blX>TUjy_wLsHxumMOlaJj33hKLH15p= zyEhXW_hzEq-J2;m?ac(cHxul>IpgEr+&2ey-<;sI|1a#mIcVHBXIy;NeRG2I?wbR< zZw?ywV}jj}3C)+MTzhA!qN&F5S@&Z?<9y}feoV0YF~RP~ z1iK#-?0!taX+I{|{g}|W9~0x6`+xD>bN{dB;vU@p3wHl6*!{nP)Bazu`+uQv|1a46 zztFh<7wkSwXxyi1LR{BLVE1Q1LgRi+)8kt1(-fR*{_ykxd*^av{^T?_-dl&KS zxSux5*Y7B5m*8BfTYmU4ag|=btd#T1FWT}^;%Xy)euuf6vAjYaDrqjxVV*gdMyxGxOszA$Lq7lyIPePI}z z+!qFRUl=s*3j@0^3>x>+g566CcE1>6E%%Fo-7f}qzZiIro%_YW?iUlB_KV@T`-Sn{ zb00CjlkP7DyYCnp_alSd4;PL5m%;99hQ|HQrbS!04;t+LXu)~+P2;%xsd3!Bx@c4P zq=wzAD>(07U9fv~p>eM+*uA>YxK|hKUR`M1tBZSZuP*MvJ>X#XfJ5URaIm$N7g`tH zkF}M_!PZuSQ*8ydwt~jm3T$l!jkOin+6o$LE3mZ{G}cz0k84?5fvo{?Z`OujYevC2 zYe}#*CN#d+!*Od;u(c{!Zy&v$|CIROtpx<9wX$Jr2WYG*a9odKV(9fKU_A<0j{?@C zfb}SXlScvTQJ~SIfb}TQ=uu3H&+1VGCyxTwqd=oa0qaqq(W8L%D9~7Mf~`ZLu|7o` zS+@#K?+?P(xzJeuf~||e*2~a4XNKd}M_!9_I`2I=)l*>SHlwlr@_Oj4%LJ#jSzx_x zGd3HlWY{_~Y#kZ4jtpBz z4o-Ds*xDBDXw8fEwH5|jBSWK)x+v~Pe-+ku4NmVF!TPjl^lxG3`l8YAh4q2a=npTB zYv~&Yr}vCteM>a@nXo=58vRdLUlfggDXfnQ>#x2U*VT80^T}EsSe9e>FJO@?dLxXjbg> zv0?pfH2U7KemEL^a#;TyjlMeVXszTwaSy&v0b45x&RZ*i2k)$vps`j0TPs0htpv7K zg2r0O%W*AhCBdmy0$VFVW32?XR)WS_32dzdjkOZkS_y2eLJ^jkROg+A$hy$FQ|yG}ew`YsYA;9lsLSvUVJtYR9m( zV>H%|VQa^*wPVH%|VQa@|tR2JF zj?q{k&WnSc zFE>5T={!2v`E{`K?t;@?P}q5TXq>+XJO2=k>#o4g14QHez!`B}=M4s@byr~L8KQCi zA?&DQpmAQ%i*f(X7Ya`6 z8^F#lLgTz6*!f6koTmi4z5yENHNnnzLgPHBsc|jmM+K)fNMYwwp>duS?EEV<&dY+G z6N|=qT(I-I&^W)5?~L;vgVTC49M^k!EAB_{B{+F6u-*$Ay%$*T1&!VdtoMRO?*-O- zL1W!H;*eB#CMNMcBiOn#8tcxmb!S+A`>QyQzISl)!(n}LH2UW&L$9wMoc8aA^&-&d zQNVf^us-t_@mXsbu(gcfRLg*^WuUQ^0b9#}tz~>3pS6||oN5`cwG1@YGGJ>NXsl(x z)-upo%YdzAps|+mSzOCn25h~6XK1|uwq5{RFMzEVz}5?bQ@!BJVCx0Jsa^nEFF<3x z0JdI$#(Dv4y#S5%0@!*18tVl-Tk8eEsa^o71nn}qwl&ZuBGn^>wm6`{|W1VqS60^^*_<*x5N7FX!P4* z{dP3^?X-nn**9@rJv3NvEjXv=2J6M4(W8U)VA1HoejT6HgAGm|EXVa=VLez_4;H;1 zEUX8MMh_O&gGHkUOTVNC8=TXFh4o-z{m=LgoKCydxZ5Q(dbpedYEYRHfa|pGw9_JASr+GxM^N7$mkLaV&JC7(h%_D-HM})?CM6mOS&^V6> zb{-KL=MllqBSPamBHG1yM6^r$n_D-{<#Mp|h|oBX2zDM38s`yl-1#K$#Q)-alDC7M zPZFHwlfcd=LF0T9*!d)AoKFHfp9GEbNnq!bpm9D4v5@mgh=rU_0-t?S-KQJma%iky zz}7Wj>m9Il5ZL+%Y~2KQjtAcp=XmV=KKyII?G1}k=y(p!@d!?HJYeT|pmB}|>>Lj? z&hdbqu>VQa)_tPwAd&srl6&RHXdtr4TKMhrVA0gZDK zVCN*Dxuk8aFN@|3z|KiPS3hZ1I zG|okVor{9TxhSx61JF1(;M2Gt=LW#e4S<~+5S-=)z|IXo|8$jVdwI}&gDbnTt3*je6Vv-_?|cyh3|uNQDEnypm8n= z>|7Ky&P7=o&%wDU!D%iE>|7MsxhSx6QDEnyz|KVpPIFOU=c1r-E(-1PaQ$(^b}d?B zHT2Hmgq_2Q#yOm@FeAc`>IOW~2c{dvK zZrHpVjd3h&e2d1o7d9S7W1LKT89&oT#?`Pn+MaPg=4g8ao1+D%91S){Lt~Bxo1>vI zM}y7L(3qpa=4fcl(RPn(nWF{gjMZUdbu`B6u(3KCV|CbA9gVR%Y^;vPSbev+ma#f) ztR9?Vb&eaW!^Y}pjMZUdbu`B6u(3KCV|Bj&&hz0rtOo__P4OKzS1l9wW3C#Ua#h$| z6^*$nY_5vNTopD~h0RrAb5+<}wRBv|Ts1i5s<62#8go_HTosMEDr~Nb##|LPS4Cs4 z8sEt@H;nJ69tEsNfkux4)}uh9M*-_mpwXk?xZ}m<=wpo^VCOmpr+5Q44nbplvL!xi z+!CBKo`H>X&=~)~#zkn1mtf;4G{#r3aTglnv8{0}qBmj^XpFr z=kzUM=VqhP=j6ElC#)~}dwf>EG&rx13OiREjlL_aAB#qx7Cv!mr62al<jF?WW2Zvu_E_J+8YIe2hhPX^YjL8FHQ>+PWN{SJ=n1;Khmu-*}D z?vK~1Cjjdezr!g>^F^e$jM4K#Wk>*KR}Ai+7k5m?Uzja~|@$AU)h z1@;{uGTq0uY*B|huB zc)`hAgZ13dn4`hwZfMNuU~@e*=76xdA#BdLF|KPa37ccW=AOZMb5ht`6^%J8T&>O* zyBCcwXv}$Gb75Go@`pI59%gV(Zxhz@M57l9>ye_-JB9UB(df0pda!8pW`Bxn>DdP7 z^>SgoJ~VoOu-+gVJwsS85se-rtoMjUPm*@mtEAoaFk!t-Gsa z$89^)5TY&?v{ zIFt4=&V-FKX;b4&*f<_N zmIzL<1Z*sU##n;mj?28~;sDI4;AE%V->zVaH|IKL6#oH~W0pK0i2TpAXyTqp{D2?eo#t=f4u4wa*VueLifT zkH$V9w$DdnpAXyTqp{D2?eozX@6leyd$92yZECy+8}Fep-g_zT*?2EF#e1;v9vb64 z*mw_(@gB#G_h92a*mw`U<1)|CahYf6xD4xUeh~Mj=NX*W3;i%yk2E-Wr?8$X8tdb* z-X!?+DgYLZjEjalOR%L$8+@oV-L>FALv0F^%7xyU^M!}u)Z;@pN!u3vtT{LPvU>kON8|pgY$Zi z@a28GG%9-64~s4zSRWOQ{wl2Ribg+{XQ)rh zGt|H3xOw!Oac}0)!8!Bjw}Q>1gHs+2n@6KDkA}^o(U?cW=Fw=(qha%CH0IH7$F7l`TXtb#w8prj}U_G>V;-2-;f|G{^>!G31 zLxc6u(CDGTdT40$&|p0@GO<>`U&5}|EQl3 zoYq}|^?cCi`M`QUXsm(4dOm3Md|*8vG`<7%ZCuNDqk?n3GX?uD6&l~Mf_?W2jqhZ^ zuJ?|{cer5R?LuQ7{b^jw{5m*q-VK|NqcKm1%?;6*Gs5PQXv{HJ#QDuVVRO>pG#{1Y z=CH82EgExP*xCmgb7a`u8I3vh^0=;^;TLf&y+l}#F*v992yM$)AA|MB(CClx%=O23=K5o>{umnlF<5^L zjs6(NeFypHxF6p^4o-Xhz`lcwruGGgtlzC@JFCz?-n+t9Ip_QSKgMTW2Ov1_`T($N z|Dtg{0oZj0(765pv5o5z5Zkz30qi;kaK6<(rFY+XKe^pk?O9RIAHI2)Dv4cxfNQz_ z0PK6^uKDx)*cA8tb>%0nO#I5G-Z#nlS}l6qo%r1Q zx;~gRt15MVw1|I=&(>?&VThcsaZc;;iC>s?$IPU8dvS|566f!|>7%4sF#U#=iAVRj z=EtNtWRJ!>5?9;zioJ{G8*nXaD8wq(R_G6{xzHb4i{beDBYr#4_RUrP`qz4ix6b>y zp`5?H<_}F0w?Ff{=1Ftt%C)VFcw_vJ_Z_>klbru|*%ePDUh~jLBa&wKzVA#*JpGJC zbCYIA$JZ7o{_N_PmnO|+Z_izo_=aV(HYRTU&ksr!{qMBPJN*~`IdSQa7ao@Qh%#^2 zkn?L!7*aPKcU_vTMfaTc_kmrPCOGH1G_dQ^pmALq*mY^pxGv3>_^j*F1gACsIqn)J zuxp#Z?mfggT}uacjUCSK+B@6g{H}Eqoc6GWUF!yoYu&)Eb%VyWZaD5*H?V8nz^-)z zyIu|7E7z-mU9Sdqy&Bl{YGBu^2~O+Pz^+$=#`S7o*Q-I}dNrHlv#wVYoYt#>U9Sd> z>(#)nSA)j&YGBu^LF0Ngu(!ufy_%i+xnqwndY9q%xKH2J z4NiM5z`m=C#&>mhgx+^`gVVdZuwI`{1zegQM|% zaM<_3(fB?%-vQqT=R4p&8nEw!qw#%k*!RKF=!fhR&rm-kIIVXC>xZDx4=Epd{gB|~ zhrs$FX!Jv1{SY+939#`48siGscms`b$e-d`#wWpPy(8Fo290qJZ2W`9c!T4{8?f;P zY`g*M!|WB;)rW!gVPJh2SRW=h`7p3P3>tkHSRV$BKFr?nS$&w`w9hZB4}(S@2G)l` zqYne?t)bCdgZ0+X=&jM#dTX?`>m$K>YiRV=V7)aou6MLw+_UQ)!LD}{oYuF8UGE5u z>mBVMpLM;X;I!Tm?0QFNT<-{Wy(2WPcLclM5gOM!f?e+jcD!R97tTpx*ccO5O--F38J*U>`b zz6P-CjO`Zp>HdJhX{ur!3hDLu3)*nNoKL+cMq0t|M^~ccYkL?-PbxjqQRRnWMm3hbIHXk1fe*Z3dZBQH4TdTX%ju)(g+2D@$>?0Rmn`!EKl_1|FE zg+t?dakRVZ$kFbuF9*Bs92(c7gI%W%wjNO??%8@oa9Yobq=!qZ(S)k)sAIu{!2Tzm&ySBiGG&IMcNLSvl^w$6pdTGDZG|He+id1I#< z!NyL(DRzR5ozNIN!NyK#jGbU(Cp5-Ru(1;wW2fWeTEuyIRpid$gg7Bt2!uyG3-;}+Ps1&whFY}|szxP|9z+`@A0urUuBW1cE;En}YG6!XBwJZOx0U}GLM#yqew4;o`0*q8^6 zF%Qq&@v&N5*YPnpjgPS7BO1p?*zpmK<0I_&h{o{|c6>zR_!xa)s@KEjiLiMhY@P_4 zCkCfH5jIamW1h%y$EIUK@7NTab8Lbgo6tBm!H!L6jCo*V9yHFS;kYpmY|K+Nu4T*< zoMIl>mpcdiyF^=M$Xv zHih+w(C8h(dP->Ynqc=fMWZ(b>sg`E%i=lfaq*n>zF<8uGl&6xK%#&g-wj`mSj7W8rnfzn^qr(NGt9{aaXH7ma=|Ik-MBxwif=JfdCMYl_Z; zMn4(WXNL8kVSQ;>zZ%xZhV}DlQ~i8cKc9AQ^~j*T%N9*}iRWT|9jJReGI~8{*u8+!=uyLZ*J$*# zdCsl}enec?d>l4U56+oq!RB9R%*$Z&H8kdNu=yPt^S;C5TCN8koYn(}%^zX&O4xi8 zHV=i(PlHq53Y*Wu`bfl4`bfk}`be-o60DDOQrw$960DB|>mvmx9|_h+LZgoa>m#Aj zM}qZ{&~*7=+DAp}gz&xkywaqVa{jHYqkl|%$=xG%B-R^Y?A9~knd|2t68EpqADs3~ zhph{sv0eaMM?hnJ0d~)HG}a?v>lA3LUqm06>KgQi);nO=EJdSt4eM#6(d&lwz|rW9 zb6oG@u=pSKG=lSb9k3n<8od!%&jgKL3arP1M(+i-Hi5>P#i4O6YZ<{gYaFn(4>Z<9 z;7MgJKDKCmEi~3pU~4O|H5b@g3~UT@VO-Z31~!HX&Kbkh57z63jbUJ87}yvFHim(X zojAWS6>O}t>>BlGj7wqTRoFQ8{5ZeyZE*7XVB=vl#>uepGaBP+*mxU_ zaX4&zj>cS!=WL$-&$tKk_kRSN*9Yg!_hEeiH2MRuz5yEj1X%wZjs63yFM&qC;^MfL zK1OhwQv{nMqcL}e&8g9tYr~E~m&AD-n}XAxzp!H&8pk-;8VVZ6MA)$sb_|6bTVcoC zc%C`OVxFyIH0;=oMy~_b13{xV0=wrg8od;b>xngt|IztT!O25|_14hnx&1x#dU3%y zJvvzL4vn53tk;J|4-oD>y>!e!L8E5~>m{PmW2CRsd!(<^%i_2m7p(V%MlXf7)?D2_MJ?>$>9W;7AuwD=vJtEjW?$PKe!Fo+-^q_dodQ-5T70+BxlH+=n zupTBFbC%k1|K=>gIdc}+oCS@!Cv5JC#@w@3eAe6(HunrpxhKcXJz;ZCH0GYLxhEQP zPuScOjk)J(aei}8*!ZVz+>h}OZ2S|P){2MCH_@1H)``!WZw9A)6E@#OW4;NSZ=x~Z zgv~e6m~X=7n`q28dA8=8JX`Zk*nAU>`Qs^ZpXQIS`D1X(A7S%HH0F=6`6C+hN7(!k zjrk+7wE1Ij-uw|Ze?(*c2%A5`=8v%XBW(T%n?J(N*NQgH=^@a*dI+!{0vbI8*xVD1 zxhHJyiN>7q)HuI6V{pnDId0Afn=``ZjOfi7VRJ?_=8Uj8BN}r?p0jz!IdKo>9cKrd zO9rQ05;m7aV=f7sOQJECgv}+&3OqC4*Bg37bp8=8~|vBy27T>q-7C z&ZAcuoc3pd^)}JydBS?3X!J;7_h&()rwZ$}qS1qm=b83r;o0ih!g{%A^mt*tUo?8c zuwF45J!FoXcbpObqj?8x-T|9;z~+*thu&N=IOUSCxg;8MN!VNxjkzRjE{Vom5;m7a zV=hTw=YAISb>@<=xg;8MNsj9YM4Kj00M-+rt<4|LjQ`90F*xOqu=yhz^GDeH5smpH zZ2pMG{1G;PL}UIK&oT4Lj{El+!al>`yw9+4u+K0!%|C>F zhG=|-u;URL$0OMB2#xtT?0AI6@#w0!mg7-y8joPdBQ%aju;URL$0OMB2#w=*<)2BCKhf*pg47)!v$5@?Ji$XSdff>SKPabq67Z^k@)4~J{<-8n42}tHCK=<+$-GY`lubcojBYMPs}Q8?T}kr%%=hPnv&g&1r`U7b62VngHSbyN(@mc+W;N%a$`U7b62VngHH2MRu{s0>N z0a$+kjsC!maV>qG;N4WH2OTSJ`Wmw9**lr-4f^5j|$G|M>Px9j|xtH z6s#YGMz0Un1BCSkVLd}wFA>&b3{Lys-5jhZ8JzY>g!K~9=q18>iD>i^VZB5&dWo=J zA{xC!+FdU(IPH@N>p{WBNH@g286&~QNWp1tA#8k$#`yO7_^k15aEfnX<6AVwx3KXo z8sl5o_!f=vEo^*?#`yNyxR&v4aEfnX<6GGH7B;?xjc;M&qTm!4!Nx^sjEfl8^cDH; z=_@vkd(c;e^%Y@##o**C!upD6^c7)!MKt<~u)ZQ1WAcB+b&bh`Q%nvUlcO;vhmFb6 z7?Z=snw z)`LQ$2X$*)OAjhIXKnP(U~8tqd26Y#HC8m%USVspXsnUJ*3QsaQ-iIwq0#@mBd(?Y z8Jzr2SpO4^{wJ*eiAMhu*8ha{KVkh(SpSpngZ?Mq2mMc2{}YY=C#?U8M*pxy+_U~+ z^I-kM;PgHS$Mp|k{X^JV40>xZu(cR8&dr0Z#h|ek!`NgkhOx=Hf3UR}G}dBZYcXi7 z#lY5LU_BOMExi|5PX^Yjf%R};y`A9nt{KOjx5jr*&y(+@o+qs5iAK*8*7HQ8=Lze1 zqS5ng8Ev8G3F~Lk0eQ)l; zIi=i#J~^y^jz&M0S>O+s~k}pMmXX(Adww_A{{k3~WCG+t2ilYuV2P=j~@;`x!L$GqC*(8pk!*aSe@i z7T9qOjpG{a;SLRw*Nt6|I;P(_CLX?77W|}pt1jf z?SIhN|G@S?XpFsKV{bIZ-ksxG#@@jx_J)nU(HMKf#@?{8H*D+;8+*gX-oYvM?iAOx zpXnZKKLgv(1gCz6HEq_ASAwMh4p#p|OvG?Yq#} zr@{7hXzT-F$2c^OaeN0HMtWUjGW#ze1yb1?ykI`d6_26|8^NHm;?A6`Xu#SpN!*{uQi$ zg+~7h)+0ruM+)naqR}Izt@TK0Ykg)|j}(m_DXd3|MqlghxMzJWSYInRr>_O;YoXEC zx+^}buN9npEm&U*jlLGFuZ2cm3)a^{qpt<)YoXECA|BS)A|AFz23sRTV;lt=U%|#* zu<;mdoCX`e!FpNu#{a071?y!6CohZRdReeu78<=QST75WUKXsEg+?#yo;bf=R&esN zV7)9fdReeu78<=QST75WH9Odv9UA>o+FietcCX)S#DhhiD#!IpVf|8AKkDJQPyMLi zG;bW%k3yp#^-$>bqk@wk1?xwl(T{@lqtNI_!TM2X^rK+?0W|sp55{%%2VngHSbrcm z`2(>202=)PSbqSG{s62$fJT2HALrDAVoY?t9IOWwoY#Yb^`Ow`LBV=ZX!M|9Jt#DK zP>;m_s|OXFJSbQX3XMJ|tj~!?pA**SM5E6M>vO{ToV2?>C++V1e^{RrjXo!=&k5^C zb%=Y`j|$G|N5T40X!N7*550a=aPp&I{U}&J3f7N;^`l_@sNm#B!TM2X^rPCxwXD+x zr#cR79S4nd9N0Pz8tXW)bsRL-abW8>XsqKfHt9R@9rJw_Sl=l)r|$&oJE75cg7uxy z=sUssPH6O!H~?-ZQVcY^hu(C9nC`kZL=IbnTHG}eM)eNHs`oV2?>C++T>a9E!c zjXo!=&k5_{JQnw?hZCGU99RzrjUEoHhl55B2iC(uqlW|Q;lRe#qvE>8)v$3jY+MZ+ zSHs5D!6~kOCfNQYICdpo9jDMZPQi{-XdI`8$2lFRV8 z8pj3LaRH6-25h{6#`+g*yn)7egXe7S|6JUIx&MS$HxC??jM}y$HM0R zXw3b`gx=ggIOYDZxj!0nf7sj~jk!N;?vKXYA2#<#WA0C1XYNm5XYS8&bAQ;}AC2B1 zZLK#5>kZP@dV}NQ|I!-_PTnA_H;6`W5Y`(+qc;fa4WiK-g!Kl|=;833^>AQ49GpP3ax{*cqrv8AXw1=I zb2K#OXs|gN8gsNK;{4`lu(@`>xF3BRSl=c%`8Ke=4H|u$r{c5vHo?iaf%R?B=-a^h zHfZ#1V0{}j`Zln>4H|tLo~^zO&sN_C*0(`p?*Dk)r}JZBbN}EpKNdFkM`P{}oBN|N z_lM2>(U|)aJDK|jr#UUKxj!0nf7sj~Hus0k{b6%|*xVm>er&X9njcI1nrp-6+GxzR zVRLOX=Gw5iHX3v7K5>3??cg*wiR0$lu(>v@hl5@Z2iC(uqlW|Q;h@pO;W-;u4~=^; zt{xI>TpgU|55UINXpF02<7zbKYq0qm8uK;Sd<~8H+Mu|W`C4$w*I@HC*nAB(UxUrp zU~}zZaUOG`;8cr;&56*M6T#+0Xv~RVb0Re6M6fv#8grs}o+&5d*_so<=0s@BiC}Xg zH0DIGIT0FjB97}@4v7Cz-xAihg!L_9earr#*S8E#z9p=0iALWN*0)5XZwc#LqS3d6 z^)1opThiC*ThiC*Tf+L5X!I>PZr&Yjn(}VgyqmT*?;aTcmw9(^%DZ9nZZziIuz5Ec z^KRI@8;yB4Y~GE=ygQz4nm@^N)_;cepV8<)!}`y`6>=3SR;YMfg?%dQR$-3{rHjVD zqHTCebu3k?>+6+@{&rx6155mEw^CikF5bK7kCiG^D)GnNOMU-x)nj%q_}7oF8@zFk zqQ4$o;ouUV-J{ess~)(a>aGRBmZum#7DiwDr`1jY3tU30YnuY(~aMga5o^15T-}n0eo_D=HOMN(Q zWB0dqjrRE8*I!m+`nv58{qgz#YOg&@{p-j6*PJ@6@ZVRz*zT=XvkU+I_BPj?Ir@{r zf8SL1%t@dB9DhH&!r>*JPw7$*bm*9W@S%s}ANb#bM>=)x(zRRn!h#+>d-Z3?K3I$Y(}9TUao9%-C_`Cp`Cu;J=!wbg7Axo}c`} zl%lm13jLcePMtP=#>`oT1+(YOoj3obmkSI2^UABQz5d36!h(g17Qgw{+wT+>y!+n! zAAI=H$Atx-ELpm2`HD{q3qJe&i!Z-g`E_BzH{Y&Wy=LvY!h-eReZS#{AAc$=`1zNO zn||HAB?L!QIHJTis!XY^n|@rkYQte?6;|xB`#x2!diRU7|4{r_bCoGodcR|@S@8T{ zXs`c$m3Va2gmzHsZBpN{%_Va16zz5Pt%558?!SW)ZlwJR1*8GUhKMZE|2s8FZxE&nX6 zIRDWj8jicC+NFgR4TqfBsnX?ZFDtCLZ2Z-8yPq=tio%K}Gw<8DwZg4e{h=t)a!udp zQ={dk<1$+AmJh~VyLj4jP5wAXiI%(l%h#W*+ppENg%$U0`0B0ktxveVu;PL3zwdtV zRX^NNSn)`?%4hbhHMwbFMUR6{yYIIH@A`LPMW16Xop|e(nl~3#3^?WXZ&xh+xmjVw z@W1sqwBf85n-^A${^#f`<_^5KWnsm{>tE?zqy4G36;@2W?eo`%H2JmFABqw!H>dr! z-}gSfRz}PHr^|jPwEC^qU4NXTM9VFF@`Pret=MvRVa2;68w_hSchK-?~<$j z^2de#tM$s3s(4-3e|4_ZswfgHc)JJRxBAO2hj*@eL}AI1eXe@C+c8h~&yXBDaY54_ z$ImN$WZ|=%xTw~Vy-r!yCPPx|+c7J9pT22zhNRxE2R-xH-}Y@>rSOH$KeXGw`&@MV z!VF2nI$Nsr{pUrs37P+QGhTK@^KYKK>gKT-k|uX89o6sphYmWr@VzwcUH|3*HxKQW zA!$Bt=Fx+0o3*7_Qlc5}c)i-H!S{SzCZiee`>b2Dq3yn3cue7Yd0^|7V}?IiwocW; zl1D1GSUsZa(c>~CJ!&o+GxE`MDpf0dmOhOxYCh_z>$+!12DF$}ZS;_KTQelXJ0H7t z%rj55IJWSGMn64p+_>>CEX$BgoL9ExgcshvsCwbEOkLKl`ox)QXJts{Y+Ac+(o1_( zKd$guUfH+FgvoClF{oHlq8S$*zxdWEZ=YGTS#7~znsKu;BmtBvUSgY`bdYtsxjFpf6?aJtyX^l(uFO?ul{z#-Q~K}2cRgUiOMBGG(3F4V zhTV>;b#~b+GBg$E9KOd%V_x3-t_;l~zkIRh*$1BU=iV8bD#wi|b8*-I>_09;Q~lrn zDtGPXa}IpHSo2@aS*}#g!GGDi#dRT6~`W>R!v0s<&Iy zKXs~g?hzILT=+capMJ!?PmFo>sG1p?hWC86--rXxtJWw(bJ>KaD@^M8+Hoy1G)=y4 zdcfSx=l!*FhNkJkM;^5Jme)^uIz!X^vXzyVE;;|Sc^R5JdX7AJ)rD`=S(c$`{pyW} zZk%z!nVT{+9ez9V@KV(loW1Y9g`Z)klfVAUp9frc-tonn5^dV^)@P19tjxj-v)Z)J z;3`K|Z@#cW)8g}#Xwv~7etmT9rS&iFkf9mA*Qlx&)L(STzzogkb8kBK@|hQ1F)c$g z@&2QZyRq8h#_wlnroQ-1jXMW4xc2)D&7Ae4PUu+X%^S+@SNIveQsw3od$s-hzmLw) zEV}0ClLoDMv)MTrn)mvCd-B-E4O?E9p;_|gv!_m9_*ScS8Jf>`YgX&E+Kujds#sH^ zO}{<;m^vSfef!?5HeG+~=(?Ymd8f^y;`5Yf)1RMdcE)#YFTVfV49(UR$JE=l=ADka z?qB#B?owg(S!J&N$HRwaXi7I6bIt*a-tAl`LsP!fE$1Fp=bzoL$k0@rTlM^t#=Y0; zt_;l~8&_X&cBM-m@13EkQe$lWi@U$ycU*?1`c2InT)Xv>{;y|fY7VK^uw{!627Q&G zsr}KKMs1f}I_&piO^G%=YwvOY=yK7APiM91`DYw^$rIx~996TZcP=!3HN1E2r6Vd` zKDJSY=CbF;T|TM%M-y6PXqtS};>x*OFMqyshNkHu)f+Ev@$r;LuVL07$Y ze1@jyZ4+)htlZKC7iDOk72b~Ku3P#}R+|pr`?*^#Xt3=4 zfyL)3@jD%T-fb-|pMCYm(=s#@AE+hbJ!6|(yY9LS&62k!-Zy>m zr{A~B(0so8?QLGGd)-e@WoW)VYb!(A%=kS}x14W$mt(3EcU{6h!4`NbY}Gc@Hp-|@&% zr~j+$l^L3f^ZwfTqzPZ{eRqcDkWD{yIs4$Ie||JWQ|0){-7fC=)&Ao%G}Ujuv&Xf+ zH9hc+3{B0UC-!Q2>&nV2Gc>h7{;_x46*nHXBSUl6J}*4hOlDv+u~`zNuC!BksLy;tNks>iNxaEsM`n;&&k#=2!eYaOsMhPn(~ixuf@#!K?nhs?PEZP3zb19=dT(von9q&~*6y zq~WD%tUi0cN`;?cr&E6!@#i78oOeQore~`cM;^BKnhP6bXr6fXo>4U(X#V$R8JdBg zojkhkcWW+wI72g{;>I!cuWfPZuwqS#HXYMw>bNW4T6;xSn@;R-%7hzhtZV#1@p($L z>C|Z(pSyEN%WF4eXy$xBbyCN@*WXaCa^YwA%F*{t?)AW}|2`%|v*@~0rwsaTeY3x1 zXx@8j(~D!Ty{+Z-8JZ<;Pn$OVt?yd3&(M6nN9!4{ozd#9ei@o?&pd6`2hV+f@01M9 zclZ4|`^!UaZ~IP$=9h`n=Wgh=;r?|Qnr+{zXwzK}tMziZRzEzP z)uyE{p7Eap-u|I;-Qx3RZf_>@Z#P-^&Ov~sot#Z;%k4utN$ArnwrCEztytU&x2NGXlj450x!3E^f7H%E;pLl(^^SS8iLj^t1b>&(F}@@#yRo zs~Y_}Yk7vI^&9O!+c>ZFyk9dk9d^|HqSOhSU*7M~!q2eNX}^8>=V5JLJ0U~U^Y%F_ z4=cZAL4yp<6Ql3{rbfrMZ#K)&4E+4`RdqLPdFSB_&4>fGudaW6yAOtCXvSPTckPw$ zZ2fq4hGtTi2iD(Iul=%5iZvzLblUtgzQ23YwokL#bk2sk8#) zULEiM`j`yOqU+E6dC-R6R{bqQ^IpH-e;IrI1M9BO&@6dp-lpmAZ2!J}hUW7mA#^JgnoEcQQ1;Oq#!a!=pQXTZg7t3b)bl`*g z)_QRNs~#+x*jVs#727;`VE+dz&3N$O;xql%k9Dt7hpc|^u+k6xrRqaRW{9d>_0Z96 z9;({^p<^>d)fYTeWA#HPlz#Zc3{lNm51)M1!>6`+xK@U!_Lzt3E_nEi)eqOp5S?}4 zBj?n5mLDxs>k9nlQf=3!=h#GBv>C_}c zbj^}Z*KO`}!-1WfW{7UQu=7o~bZ*wQbMsEnT~G?b>Zj*B%+7o=dv+ z-rV)E1H1Lf5Iu2Ww7d8=t^N4^jUTU=Av&Pj;|Gm>yz;`w4=EOvXy8M)Jbw5=eU7NzC#!+0H12bB+dfqX z^f|WpOeGq)`ocan*7P}{%o8VOh-%h;;^f9poZ9w@S{b6+W1pzI@QE|lJW(%0bk;$A z&#B$_+{S&+&k$YEt#AFYeH$$7+b~1aXiMLJ9Q5QRwV%8!Lv(rbC$H@GWaF_FggJ#VfG$%tecg>*rWd^@oZSX4@qE{Oa ze!cDB1p@{z$`CDHIQXqKgWoALP^@3q6rXNdk(X~e#D zM(ls}h>97a1G}lD~-%*;F@(to_zJlQ`?QKReYur4P1NN$hwP0p0Rdhy$sP=m7Y1L&NJs; z{ml6pq6@k|Q-9nu4HiAqFhkU6>ofnTH0qK%qb|!3UEX5UmEA`*9yh8V`_sHq8*-c+s;rwRpB!_h*}Dh+52g_SR+3w%Ypa9T}oKj~#vYMWgR+F}ihzsLjC9 z?PiU>f7$2`8KRD5$2@rKn1?SK)2UcgqJcZN8`E{*nC`R2WHoTlWks*!k9n-p*gnN) zD$&4CTr~E{7GwK$A3GpJG;r3~!OO-D-8y!7hG@jG<3?UIZd8kLqccQfiY9c<8aH9t zxQQ8}NoB`RK6d<+i^fmQ5KU`0e#XG@vu2H-lOdYBcKrOZ6J9=c!Ydh~SFfJ%db!dq)6yi@kM_cBE9*Lm*4tDpP0-E&JaL`%m#w|voapRRrG^I}nn2L7Vb z#INd1{QByNSq=PcyNRm@PFy={;`-tPWm}R^vl(gHnp3ydElh2 z8KP~ACT(9kX-C=TcPW?AvDSTlwhz{s6`JnNWD=(gWNQUUpZIchL{K64+U#OBHI;!Oh$Mkri+V~f$XNZnl{=)Iw zUifR}DK#@hCpDOIO3Nvy^_Wt-SX82c>&%{V`tm7fZYz2_t?XCNG{} ze5Mi&d_j*F>yLl2!QvMiW{4VXd+{HYr(RNb>SYUG+)%>woSVuLv&~L>327nes9a^tusVz22F1_ zd;0y$r+3H@bu2gI!Rj*}ZZM-$hNyG<8C?g>=stT!&kRwobu%6?sXqPt6cbYd?F&pxLu#&z_SZn!9fH{Bm<%u0H3L4AHAi=Dgm1&VoU6 z7G;PQFP`((x;gKZoBLjd=>58LKWsAhw>Bq{#a_bm7GjED2{pUnHt z%=>s>*L=Fp_iWcW_qm^QiSAxIb1(MH1BEEOm}f*|&&d9sQ3}zM)t*lec*b7ye5Ml_ zEO7i=&%|P9lNz5@E%3|!XOrihP1$-jRo9Zi0>8a>Ha+(2`&?d`a)0_P$lS!sY=BqR z`Ci!-q8u(>xqQ9y#Cd(C5ala=u0WG>Uk^BEt`HTrK4;-_u9)w+5(-htv~#74dzWeA zT}~nTc7k^WYwwCK-jx-iDq-H$(!6UFKVM5Bs@?H?-3jOGTc2;J5H&h|zDd~mW@+b} zD?}}-TxiwtLhA_^+UP_E3*6T6Li^JfI)+_PEpX?w3tfv}?B3+!Pr8;27P#kxi@mKc z_I0_~Um+S0c5zVJ#UaIghABkDJNk^A;4|9VXRJar?zGQ@FrP_jK2sE;sZ}mb?|5m( zgiEs&qS=m@=AOPZKkU*%g=kUk%ZsaAUfS{UFA9;>{L3pGFRwa%*;*l56L)!S?knr6 zT-l%y*$ud2KmW>R$14sB(N^Cp+v2Y5$bEH}PGqpaPED@v8E|#){Hv-3-sgDL`Sewn zu&al3Eg3BE;oR4bRJnGv8wyci6TjdAej)SyZYxB0T>S3(`rV83d!P`77x#~7;vYG{ zKT08bV(tIb#Xr{9|CvG*pXQ%f{CZN8>n|0eR}-$kw!Z$x<@#Hl$Y6of!mhtdyKYh< zK()Y`n+BK-49L15AbXxqpT0Q`2ITS!$nz}VD}^Xui5mr)-uQap4ReL4@R}PI2X7Sf zyHP?RDw%$xbcw(+O#{m*MBh#ftgt4q;=#bm3Q?7Nfz{FjYm^A8r4ZHb6jXO&Q2jMQ z4HcqBXM&pC3u=}g)LbEIQ8l<#r{LBTgWD)XZMOxtKNH;XUT|lHs7s!k-KyT~(dlMS zoycH;do8%xXWPwwXKtz%c)-1zgVJvfDG@SE*OI{#JiJrL$cZ7N*My8!h{l}>nQ$*; zQhLY~g=lKkThlw;nlbU#EQM(Hwp(-0+?s#y)Gm%Qk=26RE4JNU zb>_CULbT@D?X`LCtgCuwgF<9C@Q(e0JDa!NaZrf1`rX;~?9Prnp}Q0!r>3EM28Qlk z5V}tx+J7+gfM4jrXQ796B7+5XEpgYa>D^-k@2VEqeZgIiZFf(exqDjIlEDI>d3N_~ zp0IOO!_F&27Y2s;EC{>2E$pg7bj>f!?^)ROJoj!WM1f831rNLzvf$orh3L+~dw2cr z-FtTLfkG5s;(kQa`;i0hM=3;4*4%%3@P4e{{bveMeER*w5)YD^K6t4Ry_)#o^_mB7 z4nBCR5T)IF@Gkv7d{_)_vQA4Xm4cCbb7I?&< zs8I`}#%zyLE$}$cs0sI@CcTTAqHD=ufu~k`GQIPY8IzvOQix`6e=^te$^83I7Ai!G z@xD_6whG-u~1Ky zbX)w>9eHDRDMU`qV)hJ**}E`ipF*_%P|N}Un1k^#hZQ2%lCf^hVvh}qbytXv+r*wY z6nn})_KZ$su)v=0V!cYnc{htwE%1dwaXt&S znUK^h;iW?KYEr^$n}j!q65c9AY4;P}y-P4Dm6-V}#lhMoG0Wt{Y-2RK7}ZM_2&hhcKB-2j7qt|k+Ff4Mo&2Ky+7}HKqDE(5GRsR{d3vF0XnjM7a*ijzq79fC`1Dj zUJd>#d1&?I;R?}+!O5c*C6C#WJWe4Re?56(Lh|IVUQgAD3>J9W_pg5*{Cei1*Qy1c zz2o)Vv#;krc)d{9lEDHm`YL5{^^~PuQhrg0tQMuL*pag8Y>Kr)v?d{C?N@KsRe!TV zA+j6%#(vS8%{$&WC`4PYzuA`XX2(~lyA&d)?^E{-PTjjGb)Q1C|8VMo>!}A5QV%Oc zuBG0(egF2@;J5Ay(ebr!PaJ-G>iXL=3X$jgw_c^vyuVMopb%Z0oOWq#+Lgm;*K{I- z1@?WA=Knq|pj5hQfdju!4<4KzvMBwwt|fy7zH>PJ?)CJ03F!|MqVQ7hBEEkYIrv?a zLiA+qyQhcW#a@5+Od*PY|1PoA`=syRzf_1`O@9A+?fW-}-@j#|N`)#Jd>?Bb)3n3y z-<*A)&BS!4i5X7TkE={=P0X8{&Nj(EB(p-5b4sRKURk3}N|iQKsPcEs+_`zqKAEb9 zXHlq%)G!_AmFIG%#yhhqRHgEnSvAk!AalDRIdrNImN}1U`9w1(udnxI?p>O%^T+2{ z#Wl-Gufo|)hwsd-YwLrB&SP5JHfu=pVzW)B4#}%fHJXww$*W|v>7vs46si_ov*&G5 zrjOa0@car@+Zs74o%{B(+18x}6{;@za{kz&VuLLEhZIt%dL`x@cCO04EXPY1R;UIB zaw9TH2{=vjg?`W&Ud}X|U*q6QVH)VBg8Lal2 zfc(u`wk?w5+lS>8Dm&K#{k%J@$WeP&d4+1LZNZr>yQJi3G4wly%4y2i>%F^=$=T(b zN($Bfu7wV@>~%Zmz=xF;D%Tq3SH1hT%{6{kRfXz!zQR#02YTe1Gqk!w<(XJG^Z6m) z(}C;3Y3Z8gIq@13EI6{`5e65i)$fA`gs zZ<;DpuL4TmZ#94OSFav6Q>fBhOQoJ)ls%uRQ*(vNbX{rlAC}I}mw#AGg(~ONZ)#nz ziq2Q6%nu4x{%&PD|FEi0{;CnJ6{;dN%Z|IS=5qeVPHhybQu)hS{jjbQn}+UFS&o&GWWw6>Ux_y_owNsz3*F??G9P*%0tc1LRU^SQRdqW11ly0#2fd+WN|Gk?6EV$ovw7=_Ad zYMu2yfn$nxDLYP~+TX42p&vtT7ajO$yh7z#v))yoJ8g@N-#t;GI-b9N)Q|T(ip?25 zS)uZLUO)4t@b8K*FFRGCx_F~O$u^Oji*I~1O`-BV(y-B`C)rEv-u<&e6|}BVuQsu> zOB@+KQ=tl-+IZTf_~;U6%g$D)9(HSD(S)CGRR%JN|i7 z?@KA)m3mTkfvzos)qZuO+5I+Yn@hcVv{0c+JMw+%rT5uOo9?kxs7%*4H*ae?r*!@i zOBAY{(^}NJob_qxQstH@RQbEN?A$hI-*2i${-RJ7snu%S)}C#PSd<+ZX{}Jzar-g!a*s2ZaUtKD*1+X7e0j4ijeoa)(bTdQ5AE8kuzw{_13U0VjL-K9YLAKO)I z`0f4?8x^WvN$rPSsj~muqp=kd%57Ju=5+58->%u63h9wM6e`PFU2|S-(eArkzwT0~ zmKW$&zJ2Qx-&u^@tx(w{b!&OGZN-WezWr69+8Eevm_ik_zF)5n+YIlDY3!T-CQ`)u@XKRgv1m$N8?g zQnT^iOA1w~f+MUtu4`DU-KZ-HRrwbqoP6!}*XmvVnnG1IXryPy%@(ysM)@jKb&ie- z_1(I%_Vm5}3RU9`qmw)CcvE}vr~rkk<@7NH{G7(tSzA6(p=#G-Y_(2%@6_2A6{J%c ztajJh&3WXelGj#9xs1O*OtL*4-6W=u#>Ary^B$|6{-W zU0E+^Z>T~we#1ofPRHNWdpIggp_(>*QlQ_-vGo(m-&d&S^q3sq$@5PA^r#04m1XTI zIsMPIYmn==hYHp5f>Xyr{p-O(SFyeaJ{_kH`c&|{U2Q7Nn#iVHS zEYT)ev^TQBYMULiEE-_Cs(HchRI8oy=f$-Gvc@zo^_`inErZq0|I?DrU32zpQ8hY? zLRF;B(s2QKuC{2rFPlPD>g#1zUGq0;*=|e@g{u6^WljNKJGbopT`q;HYVa?fU5gZL zH8MK4LRIIORcJu5RjsD)%d1c|wp*UuwNz@W#bfd*R4spAQQ$_IaX+m6u7E<-?x&U2 zx|I+8VOw-Tg{o_vRqbz7Y~T98zCsFB@2^*n>Q?n+>l0%N>r@7-J?Q1?B{ynR`tg!- zwMQJYcDPY{)sI2@it5@jSnct4YuvllPyO-XnBof6w4ZGPZ!{X$CgHo13e}vS*2Z^h z7TP8~x|Bj?S;sbKV2k!`bM5~|p<4d+y7JvypKNO}wyZ*B^KxCwz_yjzRjBx_LbWk? z{h;n09op4>T3(@YJhowOV3(ZjTkZc&q1tU{XWzZY-1gnZR#K>(f8OX8*ej;};EGig zsv|$y`*-ixufxQrRdp(Z)%K{fDJF2>)eiG2s-A7nmzy#N4XM;|dBvK#whUJLV({jY zJw`Zm-1xMXLgjmGOQWFCIXms%Uq_(|vUBLwWBlAsN5&{ga~gGdI<~1omHcvhM9_Ta zF0U(ouTZ52?|9e4vS`;VG0hb!v)^|X4PLscYeDCh3RSL+yXyV4JhkgL<9^Vo3|70q z45#kFtHyP!UP-muMe6Pz7rf?bx5mzGbZr?#wN#-!RzIz4)V1Jx>o9SsZw8sYUE9q?LCh<^tu_-Pp2|i?FqjhJbCkY&fbrdt37STp}?Cb$MsIA zG)UK$!D`Rxc{skOXK3&An86B_WnI^tA?Mon$#r0;Lbbfmk@CGRp6p{Wez-zq^Xf>; zkjs_&R;WBuq1t%UZBQ>?hrV@VM=4Z}zaO0&ay@6iRtLr?RJ%7Gv+or&w_msM;}k0A z8Na)Qgv9h4TzP^*b)=`ef3MJf{U^pwRH!`a9*+sRceVe#1Ctf1vxPje_I}uCz{>Gc zbt;3^_Ic$|=2oQhfK8QE&$jRHCmP*)l5^nh13&B9GFa`PjVF8cj-5O3$oQEGRp^XU z({9Db3_M$TwnFu==V_bXN&N=-#m-TvqU)YwZ1u_0OFEEOsf!V704;T=eWyq}cG0an=e|9e1D5+r?H7pYCF#P&Kx{l-#G(+u@5R z*eX;lXI?IFr_A^fYpbkRsM__qQmt?KyCb&6ZBVGX*1OvNPQ?x*54dbpsCt`U8`Zb! zsgWlpY*MHOC0|=|r$*&bm#S=0s78kPZtq)X>!_P?4hq!-cfXT&>gO8$$i-2inr`nO z(zo%v(a$Gr*QpFvd+yBZNq3sXj()FPZOeKAIYV1?7?bPZE?rv&tG(R(M)`iNPmQsd zxLcvJNxsoCv~A_F6{`NKP;Cqe9MrGl*0FV;?Nz88-Gk`+hy< zjq5hiS)p>CdDAVlSM0dKRb3RSBfUcW`}ONTe&Vx(3YAB_TQQ*nuZ^E~@UTL4*8FzX z{zDs2SUK^CLgkZuyUg7Y2PSN)dQ_qE3%S#*|CnMEe|>gLr!rXWVE52|cgL@uc(khO z*$$maKL%@3?8LKGJ#=jutoFlRVK)7z^`GST?1VxUUGJXr-5J*=-930pp-M2n@6&%y z8chlkZ2|oqu5R>#AoJs`QWt@A_L7o028oOQAA59$qwT>FOy34|yw8xi&ql zH(>eODc?-GpimW<712Fx)%dB^tNAEY7QG)$8ensGYSZ{j3RUU)k*mVib(q%S&=rNM zLgB}I2W&hwt?#63I+ej{S9|@~J8X01>7%QuR=dvesL-&jtEW#tdXhX~ z$J^c^V_wLWz;)4{buJvQu-`n3|#(_gO6{_Ba zV@C}Qt%^A7UvKRZvtL!HWCwO7=CUg`eD zYqN`0SFN_q>*p=+U#>i-LiNYGwhUH#N&+C*j4 zW>%E?jR0U>d?h&4I!s6;RvMN*-eM~10$s4w~X<{~os&oUh)#3R& zF6rQ!L!qirB+G9@3Y}ikcSBN3`}`QS3-rg=%e)0&Rx1YqGN9)NgevgVnZADKI>uqsz+rHC3za=uvQ9M3>yF zS{?aL*OtL*@810NreQtiuj)3nl0xM?yU@{yUU91i*Q}yY9qD6!eOSK%t0z9Ms!(|} zC>$Fx(0BE`Bh?kEvqg$z8$Ps&^~$L=6)K;UB4r?LHu27ks zC|xXaskLoEx0VW3t}WlxAF({m_M2%xC{zXJl<5($tvyTRVlSg2lJLjo5g4ecx#v6sl@(zC9ngxypvowK^$Ob?=maFv8Js z!;GZP3RRO672Zbf$ZfaOt*b)SYRh+pNA8|)w{BW@oyuUf+s~<3`|;j5yB)Pu&vw^_ zl{!4$-*Mvsx1PGT3|70hMdi^W51!t5Vp?y7YS5d?OCP&dvATG6m(<*19kn~NPCu28MDsMTiFl_py%P9LdI*}thZJj&N)Oa0oT6{>A_ zYA+fUP~4&Qi!nNt!D{b0QDTW|R7jk|;Mx;(Z5gcgk-qh=j|v^I zb>fSO3YAC0`ms^>e7DX!I$5DQYtbOv=!Z=lS5BX*Q2D%RQ1(fri{qx+(-kVeI}N`d z9bJ6euP=U9sDe*4>i;CxdfU;XGZm`4TN=+AoshQe-1ONBRm7Yo8=fRh*nYkCT!rds z-=>F0Cx>mn_hOzxmDsS^wI^>n?ub3QK%shV@%@w0>8E$RnZ8J;GFa_*Z@xE;HmSNZ zTOHMEo1JW4EZTI<&Vt95=-M(^?OYBm>W|5mzVn-(mnl>Q=CUFFXDvN%t zCXdN`Z&%Zo%N459jeb}ioxhV)hhr-hstQG0|2C%38K=HKuU4q4rM5mFU8L&n(RJ1+ zRCPmtd@!c?w%s#c+9*^_PPTa)T`JF>rN?X)s#Xqd3y&?kV9&ar*DF-*=eDc;wEVL@ zJL=deRNeZu?=rU1z+Vr(+^AC-tahJ99mYSc>i6p@b&8?YFPk@4FSMwMDzP8P~4q{)#hxRjBM!yAO}) zcyNFHy1yw@+d_LR8rQXibL&_86skQZf7%+;V~ul<-<=hz0}efpkL#W8JamSOPGzv# zZgYDD#q^taU~*m6v+dEScWlf+zXS7rKdfuZV71Q{?UQZ%(55acXB<(ed{XXbs2*l5tT*g2gJ$Nqjsp?Y0(@RRWi&K!O-JV(mbQ>}J^dBb|dt(thG zdOaUqTL!Cb(SP{l2{!kRG)=ywP?c^xVs+fQPHr9CuP9U%ijDkj!p1XheP>=%sH(jk zc|LA))uW^9`6*O&?~Zyf!ExKs8Oi<%Rg+Vr-^T68b8M-5fI`)3>zKk5cP}`$Zf2lD z)qdXC+RyerJGP@)gmryGblcE;;G5?;}xnC{bwaix_|G~g4YQ;mBDIzHJ+U-;bEuKtLv+tZJ)QZ z%O*q~JiV#@3td|VtL=Ap&i9j}OPu-j^-F~+_|)9~39)O=96g?_P~F`+Z`S05^fTvX zr6^Pp^X6|zNSf$*y?&}f^|b$j!;_QmdER^dR-sC4yzpAWn@(qAkEbhCuZt~uGCBRs z**CM^D^%~^E;3CtspgffL8fe<&iiHwvn(~mY`a&Xl*|fM?$e7KCuYlguB?ZdLRHXl zN$)AS7M`m)JF8A*u-ZlEFP)y4H~!rB4OFXLy2-NDiTOKwcksxeYs+A@D-{3bw<(1@ zz5CA2rBGE%`{jIMk!t5hH^`$<)eW~SJr-BKK{av245>w-3F}cGPTm6iw9E*DO7!$tRDZos{h4P9)%UE z!NskYPp#R^=gMpgg=$oq_3r0&4*A?_P)wnk7`Eo@)CMIlJx(dEQyHxG&!=tfK5uMu z>4kE&=gwdI;(4?9OYa+$*0q(jkXfOug|eHN&`*ChG0m38bnyV&uci^RUsRZ{P`-}z zA2Sn^JK0Q3Lg_dCb0*}hLjR_TUe!vOOe*~a1@-j%sF%s4p0STlhTRw|wuwJ$CmLgA zjFs^$`ScNPd_IiNhw=F^J|FyZ;b*;4jh{lAaE|ad#pD8i+qgsVeN7NWMW8Clr8~1^6 z9~k$+|MfOAo}+*6DKW;)7&qhR#6N$!7@r^G^J9E|jL*;i-hI2Y{-5!e&XlQiCUOiZ zG^AspBKWq0O!S=yIq7#U`kkA8vu{+O|5A}j7W$o)e$#g)n9%nynB<_}f5CSMd~i1F zzSH4@E7ia1+Y>&T5h`zDYWjcg&=2CJm-qP~V8&cgrA$0JMG6%$c>iTClgZQ>`*j2sHsPkt=@Hg$J zXrZD8d)Ty`scE@PpNh9>B{R8+KbVtgJ+0(}2 zXtqzrR4XxEsJKDC-^_H@tXV6{Xa7XN%_?Q6{4ULAwI{~2}uas7NW;HG8Dl&Muz%PJU5N-Hr4QHDzXMg4sAc&KA)ifr;H-*KNx zMVRT*7X|^>>KFu1EBSPtKU4Bw?dPNKxB_NHK9wLatMnNq1~dAMItG#Zj5`0gem;IS z^z7MlWKk`vU@$4I#2`c&D)|@n!#44U^C_Lx{8L#4vq~8%`LL&Y8EVa7Rkhasru}^M z$jFzi=w~Dj3}*BhbqwP38Fl_~{e1LbD3hHo&APXn!K6MTcVG~_c1(a%RWae*8~ zjL8NwUBbm+C;Zvyd~|!8Iy*a`EUKjx3_8hX0y3;!7v*J^E1qP82r<>X84Jp^bfv}q2DHEd12ncq{9TmoQDaANulqAF-wCf zN%=Z6PmnXrDVQ5D8&I$n<^)V%nAR{8U?#(yr0=6K+YDm|6As@)mB1}1$ckty0 zO@`?T(+(ym51HIBC1DEFpv~ezu`p|3X2CeY?1rfaQyQiN%n|yo9<%*0_Ap*B=V1!b zxA&N(gOXsX;LFY+`o}*^%sRo0f|&*L7^Pk?M`3(nN>Q%ItSXEJOktRzDCi9H6U=;= zg)mtu=4SDbWCj(7sRh#krXS1#m>rNTgINya2y+6)2Znx3w22x0+-X`Fn0Ofa0l+3^ z^plQF%;<;dnwZf~zcDeRAKzhOHW-F}beoA;FPLdCmM}%=j~>nF-{dzjqaQwCVn+Yk zyouQ%_~_rRqmaN1gBbxs|5C7t8U16lCT8@ne43b9!wjKKWHt$=JPiG#IMgUiLztE@ zZ(;J#cS@O^1094p4igM>ALa#0AHgKU^rmmAGOGhq17-?*{b1U{^oKbOvlV6|ObB%T zFfK6bU~FLy!?eQ|9S2hcCKpTaYe|yg&A!@raw$1j0=n-%zT(hFyF)c08!1`M`w0q`;UIAhQ-`Bg|bGFPI--D#J8^83;29=6pdi0WeQtX28saafB%WQxGNz z;w+#cFfCyk!gPd*{F;m>%ng|8g~;TC8H_JG!_Fl={LPhiMNp5@sgM z5SZtfTO!Oen6Gi%SA_WirV~tam=-W2Az25r5hfiza~$niVIuKmD^MSppJA55jE9*9 zQyFFlbZ#(NaOM|?7+!KA_z$EjW&CI`$|m<2F1 zV0yu{hOtB4NihB}5ilVzu_%p!$%*Zl4mtqi2Qv!3UtpHOjD=|n(;Q|$j1!ChIxxG7r|_WafLYo6OI|B!kFTuPXZl-PCKt~4uVB*f9)@oPpU4vlkLCn3GHlvj!#)&io26MPOdSHyN}NW(CZ2n4U0>IOKnaIS8{9W&unf zO5)1!E@VgKhg~ zzhobF5nlT2V>HGX825qkF)$tl#*^@ioP_MXV7yKn zuTeNujMr&n42=80_!xZAqriBb#*6yP{IYm%YxZYvkj8rd&Inkf*qYJbVdKAyKygq7 zf&QKz`;}3RXhzxBpVO2S)l?(=b1i?VTAvfcKi3!iMK|{AFP!@q@8YkE#-EGfKf2Zz zU!8w+Gz* zuEl@1cjIyK_t}TWwNS6ccOpP6C)H1Uk=+OsHAN7*{?7#Je`ltOVG{zVh_kr;-FdhZQlkf|jgvP(2_~jA-#=oKXWp2~| zrdIU-hN7kXt6fIG-RL9HsLg(rYyOcU@t9g>{~V1b{^H<^Umcq%NBG4%{r_t4uZs_F zxz1lX_b=YXUl)!4uSNNHh`;#i{5!&;&BD0ZK6<818fFWvLT zjr5m>*SLHCq2V-p>rw=j0 zL#QD=HKaxdviBOYWgXNP?a;9Km~|jKt)W$y^Qs|-szEK;jt=B_YDuUq=dM95*+nhc zpbn(CmIiWOwd62#z(!cJMs@l>%>uxtgD=^XnO_vuV4XsviCN3=uwejL7Z4~w>p}xr zoreY0Sy%wtFyv7AdZCELhkyqPAT>IWBdjHX$B@LeW$(3Q7aG)*o|@W}HISXwlq1xD zF9C)j=gyY^9vUqRZ!N7;KINL#MJ;{cLnRNdr3Yd>^rZ0dP!VrX5BNUBI`Ux<$i8$% zS}i^cs?O)74_X&I8mPi%L7;hLb($k4sITE`K^{Ypeenm4YSomUnsV+skbQ|blUPni z2j~~Q*OYyUeOODzkbu`B;9~mH5Tr&#tX6fgTL`3>Kd00+e+K^meDGAnhe5!XKp*%_ z$s@(uP{rVD$ypG{PQ^^gBO4T3lstS<2xQA20lrK$JK3oYWG%5=e*igDu@=;$94g7^p@x+$YamvTisUeKpjDSGi#Ve-*`S=Vh&&bX#nqsW zjD|k&TIAuK67atC0lj3Waf-Fj2Oc`|@V@kc54Dcm#sqxI1bk-_$WC=2dlySU8m%2Z zQ}W0$i-jYP>|KPHI+9}+;U$l(C8A0m*}GUB^2pw`>f)iMdC4)0ZA@Z0cM)guXv5$@ zJ@PIH_;?6-7y3m~BTj(2atR1zEe*s+HL9+4Awi6gig=(1z}aDGY#d~RV(|JJDHc(! zE2ByvtLs3D#p6&{yi=$zv0i#0o?My^UrGWwLVl`KEgfjnkz(;&P=j*tV*ijwhE4#j z2(M1SS4SUsr|`3A5b!Pt_@d|oZ<#zA@F|mr4?_<`SnA0@5y)B}0oj+B4vFP3#0TJd zGMH3Ez0krD$a#sKP*3g+0=_x~a)e?hkVg(fJk;coy^FOVkBo+{y6jXe3Td?7`B3Z2 zmi0gc6G}E_D&mg}0Usd&A0YuB1_2Ky)iMNGODq9t_;d(lb+O9iq5o5$$ZEA@U*ajK zFM~-S#bV*eBWsDXMNcetIC#eB@vpaJmDhqSU{9}@7W67Y!WgX~lji`AiKWi1`Z z>SEzYBWEF2nLIoQ1e%x!N>42I4^2l_*MaO*Y(LV-(1|Td?a=>e+fb40OK2KO4*{=* zxH0j^Kn#P#e1rsiJS67BAmFngkQy;%@@QfnL-J_ASCBli9kE*)%8z^meDL}pYl-!u zBH6px8#F@Mh1ewIkzHsYHX9Yu04PxUz(YqK3F5J8D334dRMrwpNngsLiYe2VSQn&6 zbU+Ua_t441fL2F%s5@RnpXfdSd;(|fXkrcM{lbgz9%mW}GKR9(cUre4dkL9rI3;Q`kNzJlc8 z%cKW%+VFvioj?zx)QEYJN7Klb=@AyScvwiI0S`ENG_eG+7NpcXvSqOZR3v*Bkw>$# zFF8}OwWvrAgP#)grI@=u@ap72Q`w>6we*1xgFJk33HUJdfzOMjP((X?Jfz`ENzL+k z5lFEPWbfKg#o$RS$D^w*TNWFD1adqgP~?$A6>+Bbi0qwSH++O*DXB>It^?Vru9lpI z*v1gZcC@}&px6P9B7NXucvw4s05%=+@CgFg2mx$h0Qx^~Py;p{D&k}Q185pHFcfJ6 zlP&WgpooQzfbR`})QH1Vzesi}j%6y+s%s!S4HEES5b$Aq6!>`b#eBWUqX8cec?^L^gNpdTD2X9E71JS)>|F=4Q=M3=E;|)d zrs}d&9mr0_Gf4B5Qx>aCU&>B(AUhS0Cu!tR#TF%x>|Jb8^2pxBUL_A7DqY)&Y5(X0 z@02{eF9O~L0UtAgR-NxL^6)P7Ky*q1SxZciJhD?UQ}Srl`6eNc?7|Sl3eqQJlCi;y z-9jGOmrf&RA@&bxu_#F;#@9kE66`CRs{fgH2gNEn{bdh zM8K&?t0iY4;!H(yIwIiYks1+as;hZ4kX?w~Lbc@Jbs(oK9uSOy1zZEVSn(hb@Db{P z5aaU2Y7y{G33xOJ_;~bzPlr4_Eczg;i%m;i%ic8*i%W?GK2$vrGo>oBcM%ry$YF?? zl1KKgt0j9EJAqo3y^A%ik5oAx83L_$Id}1xQ!UxM*pM_qSxcOUq|y4)fRBf=5WG_Y z9&kMnosx%lO2Ai;fOks3`_czKRPyk?^gujlbnTU$id80$>|F=4Q?cBn;ZfBG*}IrB z70G#ty+Iy1R1L)9(p+SxVs&V!a`0kHkVaM)Yf2v3so0R@k)4Xg)vqP1i>0KiJ?}yf z#F0WC-ZD*`$A^HA@S{Mz(B9A!i`AiaWOW^ojETjfkcO`g0UsfO>`QDL$~j1nh$?yb zObKLlv6T8^zD(qi)6t1#Ut%d~RMI2DOCDKW1QR0{gBLr2T9$LyK+`ZV{0Cm3$9(qIt$OdWcg-3!&O499DwoFg>ht;)q&y#4^P3qEBkqFu%N(t+%hwwEYkbB9<179V&tke$*4z-#f) zk%pcq;gKWcbEhI6I)Lod5cs0#i|J7p4YJ5n5k2bMqX7>%715I^#2Uyc(@7zU_)IA! z!He{PPlshIAl3)oJ9#wF+R=b_K^h5S$}D@r8x(OS4G)w)@OkNpMKnmmgP`Mo7gksx}K+aUGDS2dH{E4HwBA5g+2y~$HG3!9q(uw8V=|C4^-WT0t`7qdB7exeI ztPgzfy*ctzSKZADB=ty_B^J{15O}Ar-fGleMsUh6YwAq@J?~pT`jF0*{N89I&v+j7GE!Y zkhMg3scYGn4&;=@XB*PU-mxweRSZ~w2rtcxhgT28ekQTjDF^y^%EkJ?yP(1FE(oMY ztOber!1O_i#lle$#>T!WMD`_yq2Cu@9ZXz`c@W6*h!M!XG!TyiOWE))M0i>5MtEd9 znpi9feFur0vIb)F>wR;H*gw>u9H9nc|IjStyu_9uu?&LPNaT@y381;~c@gmH1bl+H zJg^|>fd~r(qDU+XdHAB}1D`T^G~i1>Sr0ximJmUF2&6^_e5h0;Th`T*LluG2`^FIw zRjMWDr2|<@>;#&Z9FN#YruD>sr!E1?)O&YD1oU$es!6c0q1UW)2hGHj>hVLH&Ia3|T2?{YK1-M5acrEhqcJzuw zUvxp`y;Bh%4|({Q^+0St(#Rq)(+2XP)&mhJ67%p9@MsWdeQCghNdgUIEwM~gB&&-@ zn7))V6&rv&a;D-rrNK*&4rCC-9wUwHO9!%+*sG+GeTj`t_k6y?KMFjCh!Wf3q~To< z@RkYqF!X_UK~LjoQ;mMg(NWVwX0hDVBP|D|hX`Ib;snTHP!K+>B|D{c7e%rb zMOAp@Fle`k24(N`#0ih=lpa0q(ShugqWWQV*{L>Ek?o>et?X0>^wEWXV+0!pPH976 zwNS){0btVsu)YZBwBha0MiL;~p*_YuJQ{xxvkAh(rmPQqUhv4?Y4?hjwOVZM&`97b z2#+=%IaJ!TVi>YP9(fYeb1I5t%Y2zoBs--8L};|Wv<5Yheeq38)ivM?M;-yR7Q7Y# zucZfK7Wx`Z%zLLIK2!oeLcMPs5uwxbi13n_&y;}2hk(yZANYEaM*@HJ*cr%zfO9~C z9EMIKrz18AiM4h#ki!s5K(muYVlBuc=cNNVR2m7^0@eWJyyy%U8V+>5<(=w*SPSy- z1_?AVpBH)fF!X^BgFJl9EMbDa^uB{cECFeFpa^7xVuI9>tgZt&3^91p@aYg})wKrs z+^J;=VvFjFWi5KS@DYl&pkr3{E*@&~P(?Ni@lcaTt1deg0f$nEH4qy>)?%$e17P#g z2flycVG{(f5q=!d9cxF5`E*FE1F1m^OpH&e9M}p%tO1)lJW|8=26_0J{sCmCd?%oY zh2;;RwIjRG#Nx$4qms4wa#MA_UT8SFBF^-|M%EIqb@IqDi`5~IY)~vN zd9-#kkRudvrg_PBbRgT&G~!*!k~4gS;(bchWq36ZV)_WjJJkn13-a)25b*I3@VOK4 z2K9hHDQHcr#YadQzJdgN7L>b??T8IY9@(i5WT!f@R$cb4&B72wSXiC}(IAl3#lq20 zWv615$s;?}fgGyXqNI_%i!Dkm%PH&H;eFAyo%GbZKJZS-qXF-NJUmbYT6NwRc{Jcr z)fdZJ;z^-qHIM8<%#>>JtxmwltOw%xqAId44aCN#kJEBsVok{-`w|+Dr{=iCw2R46)g$9od)Ihm=O(9(~|_kw*hQR6UJYK?*Vtf zE2pdj*{RqYq|s{eO@f;d+r3mIyU+m-FYZ%JL%@ehz(+_{YIML`*004oB@Z7V0q=`| zM?)X@n5kvisSafC;_W~g2HB3-2`oW@Wzw%MdlzA$B3Vm3MC6gf5P_n0Wba}FkVp0| zHm&}5kjNm2GfKa@oU+)ER3!V-ft<3~BrNBES+FdG02(Coh%P91!Q-q4V$Axr_)N*e zM@Yc?(gz+N^6;V3ZI{oKKz1sY34SpQ4aDBiuO&Mbt4u|*Qys`o#WtpvWv60M=u6qV zIM8T>TJL;y^lQmZ#o|(toU&L`^2lL`r-0>uL{zz$5&|0VzR1JFLcqJw(})q$VECF6 z@PX-pSQM&71E3Yu1F?cEpCF24b@5IiF%J|0j|PG4ODrYJrSR$^yyTI6i4`P|tS%OX zJhEjS$eD`x&|ox=20VsTq=9TlYzb;v&O)pOeJP{LH2BA+{Ilu*J3~e`M!*<>e{=*a zo`?T$|I14Mf!SN6glEoF!}vdA1pc`ZV0*Y>CgcB%5%|YO06(=oJo7*Lb^T8myv6g2 z|ACk4|3DWODHk)F-Ju(o(HLW3+y|fa7=+?+F&bkGjQc?S7#LqJpLL!Xk6mM!j7N$3 zC^42AKXd%?BNuMk7mYXVFLGNqUZ=m%RiAz0$wX^nmSK|#Clk`ZHu1~P22pp^`ptY{@8hCVHrm}S^xM=KdIInY2xOoks4T?7C9Cxty_|AHrl zJ!QrxMgIZI$U~y5n~^64b-&EZCBqL2Yak=9lng&8A3S6kdAVfdmGWgC6!xTL_?5yM z$jB>&)y?oLg*Bkt@qdys`k?5&lXNeLKS>#WTr&Kme8I=%zxt$nna9QWq!@f=`y!7E zzVp&}ou;?!7x-n7b}_Sg_CleV%uP(pBQgOhnV6q81^i@UZl49P(8N43YoS${&G%(8 zF;8Xc`$WMxQMe+riTR=2AUC2Fd7%p8tk#|Y>Qi&W(gZ5-KF;D&m8l+DEa zSOw5+BAbe!CD~2Pqbot@LX=bmz6c`UY9Q+zCgyuzw}CE=D6k!TZh1}2T{?imh^#t+tiCca5AO_}D^W~W_@am|bO+hyGcn)(6X+yS zN>At_iI((%&mq5wd01c21tRzUpm-wNfzWL(U}Ekt7(RcZmqS1)ME=7-P6bWO_l*Ob|L+&<0$}wsRn1 z+%kq~gg#ab_ZFo+xPoZ|2%T3kuT?SHh1LvR3Gc6A7)t1|mT8w2r;WIdc~25LZeSQm zm~6+;rUdOpd#3du4BgDU@r1`57;Y{}+tHDsH(~TPri~*^+QGa|rD#X)V(3e_cQ?ai zLd##7c4ukYl)o`=2w~(thL+#ZwsdCbOt{&FX@d!!4l(blGPE-fGdxBZa)jZ1Lf4~A zyP_;@&fl5WlhE!s!)U^I4~FZ@(f&Niw5JIJPcv^4p^GO&$8TwidNK4N3_r)TiG(rd znRjn_+NBp61`uw)#4wdG8 zXxlzw-m8SSA2aPs!iXo#>s*y~?o)=rg!Zuv-w`IpG3~x;w0Yy1_da28BEuEcY5OKI zbR%5xl4-*T*C#XY`Wm!9C3Li6xTiMl-{lPb2?JI#Z3@p(*8Zk@FZc%DW;7iT;j>R4$Wx)o@ID} z(ES|4ctYFrOuPAe+P@c>*PrmE55p8f|H}-Wn$!Nh%Cx?OZobUBqy_C?e}*oEQP&xU z5PIEUTFaKSe}kCUop8@hhT(+oLKv=UMf>+Q(;g#?2xZ~K zOZK5=a|hbLOBi|+MlUtRa}h_F^b7Mkb)@~foS`q_-jxiK2`yJM?aofLf7dW?2w|iR zL(9&ze{C5$6K-D5w84Z;H4380pY+`ty&~*#buINhpcPsOH654HJ7)=VtWSB~r za)@d7{6zcr2=j&#hPg3Z)|2+{F@}c-ZQYsnHlc$D^V;;H{dWY zZ`!|Snb(`p`W(YJ!YFTs_I+soUSL`;!mB>an@qUpGQ*vHY5!hfc$M(>HKu(@7~#jf z&i!crUS}9gXn%v@JHo_3rrp<{_HQur-X{zWVYp%d?cduB-3V8NGHn>)`Y`5QKalqC zJ%*AA5{(Z!}K7`92GfX7BAH~pNFzw%HroBMu9mBk-gpP3x_Y9%^ z`;4JKVL$@YrVxfcXWm0YY5%@pc$?7X6~iUNX#XZNtqWmN3iCz~`ld3p9!~o=jiEcC zuN?ca3_0|_s0VA?doz>Uo7Hiq`^CWc{zR$CZajivqTz_hM} zF^|tJq@w9(`Wq5(m{Wpg3gtq&ccJl<g!ab)L zh7-O!&2ZHe+P|JmdyFu`i+N)RPkS@8n@aol0z*&2#EWKy+%lO*Po=zST6c4&OJ;@I z8~hM{_rK{qaDL&PXT&xuETe`AjO}Nbs^f`0q}X5Qs#xXiZXQ}%$_@e31w2TKi(&2ZNQ2?ECb_wt; zql4={BM&;E_9em>M@LZ-qn&i5oPG{pFr6J%NuU*U90V{rP0zi<3-}W00gq&~haS{J zFX6jQ4`&J^YkJ7MU%}@^Pm^`BS)p3yFWF;5S&n_LQ9#d45(C;4r&Hje8C$&pETrxT zy0bKgQz~3YbHp)1n#27qd`NRxra^}^M<7vqmgaCw2O-T7#RzE**LTn%&9US?N|EO9 zWrQ?`y=4~iA6j~_>ks^V}vw^yA6Cub6Bp0j-@$>D5iAOINE}c=7?g1 zG>7Xt=#b`EvL2;KbNDhsn!|nrd`NSI6Ln{44mUgakmg8ZgfxfGM)+8ogA!WZS(+n) zh+;~In9C**(j3W*kmm5(3?0%O>$jj3X^t>PNOSCUfDdVoM5692&EdTjKBPIU9YIKQ zgfK#yW9K&Lkmg7tYR}Ug+d)WkSnU8I%@IJkmHDAgcOJC9_WzaSn?}Mk>K!Ug#3p6UigsS2q)@pj_iipZ}1_xk;VwQ z4WE7RA+=$KuBc-5Op_4Cd26{d`M)( zF+v{0{TO^mV_5zU9m`@6QB29_=ng^-BZ?7H7_P^mLk44s2TGB^@MVPjh5ZTmkiG~f z>TZtgh1*H^ki1A^gxrPCDfn3G;xu%~TtpC2Oe<3S&VZ1&NM?kzg_kFE$XcvFi&7*l z!WbcEvDXVeq%0DNx|<_o;e8H1BrL4GLC9BxFhaUw=XvOmtw2kh3i%5kfB&| z4W&p>_%cF%!rm7?q$k3O==>vc^Men`i8MyYP5Ai3ht!1ab?A_ph#;bv)}iy5q#^={==>vcx&t2)5pj%=hj0&t4`~R?yU?*L1QErwF~u(ogd9W^ zBcvc)??HzQ#FG0cMFPT?5%LfA58y-kA)JWLKO(nq_>g=^V}#s;&qMfF>LCI;WF8`j zD5gX%k3h&fBr`(V!7CCvWF6K&Mk$gGVT_P-*c$~OQVxkkbpExZ_&tFS2?y(F5b_Nn zjF4{F`4l>28{^3{i}bVsK4_4jG0e&rym5gD)fG7wnVZLwX^ch|a%u6u%enA-Ryo2)PBH zm+&FAVEYO>WELWbD5gX%$sptvk{Kbb;Po0hWEIw@pcF}kFh(q=@CH7l6cUN({Od&V zON9>!1?#sUq7BM2O*bW^$vtoLI4q+e_bhl@8LrtA&yZr zk-KG9@*$02xi~B7SQdeZVoE;8B_QMwq8K5C;JOq#WDu4tLn#snzKoDRu>S=r6IYQT0YbjS=u5K&BrQv4i2$O|Mh zLR!FU8+6DDtly4OBn844v7EpT_>dAvB%x;u*4aqNC5aU!u{X=0DQRqhZE8HN95)LA1?oCjBxk&IS3!S`X7Q0H~$DC ziYbxHVG!>9$&7IA_i}{}xBm4

M@`7$e;I_qxG{D}N#poqv-len;WMh2Q!Z2>1OE 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 literal 0 HcmV?d00001 diff --git a/dataframe-parquet/tests/data/int32_decimal.parquet b/dataframe-parquet/tests/data/int32_decimal.parquet new file mode 100644 index 0000000000000000000000000000000000000000..5bf2d4ea3845820abb3c5e89b486d0211d2c1804 GIT binary patch literal 478 zcmZ9JPfpuV5XL7a32w!b7uk{*){}yWWGvgn`6m{z1BnIds!~;z7yB6^o|E7|X;G9N z2SBViKo8IZZ~zu8I6x1;0a&u=mndv1%};u7zR`R$nzN%XJ|p&AK{(9Av(9wOsGff! zGO95VH2`VAt1Q1yz^M@3fj96K(j%}ph;G3vIIR->0oygAU*In|tP}kP`X-SAFQBax zJ%D$xG-R6T9B<`QkzM`Bqf40zrXktXjM_}7QZ_%cD$}XqnU-no=oacjy-wy@W>6aU z8e&I3_-_T%!fNP+>6axQnU>L-WEZZO3OKndQ#Zd>F5It6S-9Ecr<=+`#G(*}nDgRh zD*1rtMOH=ycX%?AD$e=f+nkR@x|CKkLRnYFG8&~q?Y5nuh3vRgN(>bW(Lp-=qc&tYcYI_yatim5cQT)T?Fw0Q?3XN~Qh^T(6b+9q<#lTQBt&puAk> z9QXm;mP`E^_zPUT6t1Yo{#rJs>D5^lUb2MZ3c}JxMXB9TUa*Z-Ea7$3uvAUenzDu^ z)s^c=1se;e@l_SODewGqRjMYg5{b45%}On#YD#OAUYK-D#YA&H+{~`IDNb=+u-r^X zw`Rg}8qu68!Gz@ZV@7%;%hMvvi9zB4<55O>ClfNH$;_IcRX9)?v2c)3zG+*A(-QKq z;G70rREieNAdBXtH@_rjE1^xc1=g$42l7d^w4u1r_=3vj?Y}* gqHdc79lzVz4tzKE!gd_nc5HiL3mu6s-WR{pA1Og=zyJUM literal 0 HcmV?d00001 diff --git a/dataframe-parquet/tests/data/mtcars.parquet b/dataframe-parquet/tests/data/mtcars.parquet new file mode 100644 index 0000000000000000000000000000000000000000..cbf0c163fa371dd6de71906d69c19a3d06155e57 GIT binary patch literal 4564 zcmb_g2~ZSQ8h$;~bHM-u(rwRX0wYR0$TbIpBCgJ6c-S_bs8<(bRGNt(2j(z@E<4rBX;^% z7bc~Vo+kuhYV%z`)w@5W-Xpp%Omv9?Q6LhyJx#f>w8taE%l+8cg>y^p?lk^SR7Rzx zXI?c(Gi~#xtG++Kctc-xb6jlMg1CKsn=0mY;h$HxCQ9}MhL1Q^QW^N41APMWNleGOcw_I*G)eHQu4cj8h-F*Bq@m)t%$dX?!wZHSzyfZ6%ec`@mF$X*|^t1W`YC4<86qq*E z^Q#Iq`7s`Yze(tq#&eny%5Oc zlpt$)N@^w{B_Y`$AKQjWNW6Dn8MR83?U|1(|&CE*}6rY-e5#tOfwXgh01VD%;IJ(5>LUwCneM~4oB>^2gpm63Tg)^U_zy{2a zfZLFT{WRc7WJyK!#yVpXQdS+)beS&mP!PQSAd(C0iO0zM82!MFu60xVu6pG3Q9jYY zGZ5hpx5IGYEdU?|g89nq!hB^WT+9q(5|{q%Cdg$Z1u+AS2|0!9ENctbG{oPZ(;O#U zXf*?~H4hY6;)gA4e*a;fGO0ZmE`-BDVxn42QPMB+tPxVF@OA_{xnVh2v{n7E#WLAV zDirdU9(-i6BwAb4*;jd4D8}$;OzzAG1PlO$>kuPY&zj>{MmS56gagpp51>GT7;!~q zQPPco}>t%=%&Q=&UI!lp+ zK^G%7qHPuAK(`SC#idfp@~M8!p&Rc%y0Pfr`30lPqarKJSg}Wocj(z?yM0AF*LJTS zf98p;b?eCChhNqb`HE~G z>NCLXeErNjgOnInJyK4I`(0y_>MR``QdDAov=iz|Ve!@fz@6#z#kU6%D%Z4v zCDc)ztjFocZ_zb!L?~w~ziAtR+|DOT_y%IUgHY81kRs*)z#$Mp7RX8-V-5?8jlsf_CxY0fM7y(U$9bv2|I^jh$9|lO_8WtQ^cX$D%s`9)$ zd>QSF!Sqc(uZ`AsON2rw1Fm0dr<|S9HR6M}A}^#TJ#KIOY-EzpDb1G2(*;XhBeL6m zUg0`ud}PGsmn z+U^kg+mVh;@K$np*d-vJ;`Dkyy2}UU!P&-dT4u=3C)T4pOmO6(Rsc%4Jmjf4S_?EB zt!uS*PV2a&j0>|TBNT!#xY!oX6;gwCvBnO%M$3WStKq1BO#|;U`(&wRm9EsL;}XCN zgTBo6xvdV7YPgzwT&)fc)dg!&CWd{SbKsou1KX9AiQ20Q(Wtp2H;gz&eQWu_VF6(=o6TwziMQaJIsv!dZ%Jqg2QUwE^8;IReoW8pt4X z)$RF+M$w_OdEk-Dyrh^Dm#vwO84_?cGqL}~z$6VKpK|m?flE(0Z3`MuBlFFy@3k1& zfzj6k>GD8SBOSVs&jV&i^WeTR3z&iKTek1rgM3vG6aJ8|*{^)%cY9%iZbsFoxz8Kv z^PvO0HD$5h?!rhbMmL1ecY6?_>8BensQmM8`zGfN<*JjBd;AAo%AD&)x>NAA$9P< z!nI(?0NYG6ZZU0{y(OayS4N`AC_2nrBVf1bUu z_Xh?Aa)NL{5~gVd1dn+_ogXJ-+ZIlU&hUO2I(fK9RQl{PX6xC%%QxE}Oj+Cmi@W`i zaUPIRu8_A5`FF+}Zn=lp{J!aSZ)gqFo9-3f?1}fL%l6O;>Cg(>P)p2EH#gZDNJ3gn z?g{`Z<>bta`#{|8xEi Du6=d^ literal 0 HcmV?d00001 diff --git a/dataframe-parquet/tests/data/transactions.parquet b/dataframe-parquet/tests/data/transactions.parquet new file mode 100644 index 0000000000000000000000000000000000000000..ba194bdc5a86b33bce332242ab36d6b6351ebe85 GIT binary patch literal 1746 zcma)-PfQ$T6u{p%JM2!k3kAzJ`AC*zg0P}lS4t2`+!9z8I9LgVa?p_C$IQ3v$S||c z&O)1b(xho3A;f?2KqALVj3K5;4_>UA9*k*>Cl4l?o;-LUHmUz=^nJ7JGJ%?Sc>Csi z@B4o5&3oUQogJS$M}a!j%~FOBaT*60?o|MQ5rV%Yxf4UBqO_o(7ZNBM$|;m2%4w7o z3LgK4(C;5A6MY%|`vlXTLo2ChC8l|IPdm_3qu-og1j{|9IcLjT&P=h|xczvHv31>#;j9 zH}2S`7!z0cTE!7L(_1@(#$|7Ltoy7X{U-N4C)W>z7S01}M~lVR2QR`*ClCQ*_z4KN zm3;25jtWSv_}qNsB9PI-7M>X^3)`}zc+BU9LN}xNyi1Dpd}Qq^HQy}rs>r3FSJu=Q zrJ8Hybl6J4kSywk9h5DfZ*XU8FuDc74s{|W9)6`x-Fpt;7PjK69)R>mG5DbcTGYj} z`vAraz&1tZJr!Q)2KXr#_Ua~~B1~&YfZF1)%v(P zF`gTNUi8XM=)h$56#9CE@?P{Md8Oie>%wwV&}j+NvE8X&xXfVY5*YFll^njZGBpk9 z#zy2_i_tHX2=GhtZ9Ok}!)C;W@sj7SW{rh;S)>b|Yk6+gm@ddwLHHOVYZNDO?_d^& z+R*qScg?b>2Er!;aEgpp_=;D}8Z*+{?-rm&@^T{S%kB|VF?+_!PV)SSH5mptEd z19VM{k8Z+fJB+oWb<(ok<(j`zmXWE_weu{nR;y;NTFXmSjJC#UJ5Z%hVQm4@tK1B1 z&t0;uz8;_|nv-H(xWQ6juZh0S3!5G3EK;#4De0?Kg};Oh+vz(rwMQwz)ryb~iI4L& zujU4QNvhIRc2WK|Fq&EOec_r{8<2Q4;6bh0SZa=Ea+wwybk>#b+RQ*2--SZg@vNz$ zGkuHb+a#mqnJPmtRel?E=#Q*AWJM)J4Iz-bHY I#ecJZ0q0nRH~;_u literal 0 HcmV?d00001 From f76f5180c81068cc38eb918e67e9b94a99413644 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 17:29:12 +0530 Subject: [PATCH 08/21] added some comments --- dataframe-parquet/benchmark/Writer10GB.hs | 49 +++++++++++++++++++ .../src/DataFrame/IO/Parquet/Writer.hs | 9 +++- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 dataframe-parquet/benchmark/Writer10GB.hs diff --git a/dataframe-parquet/benchmark/Writer10GB.hs b/dataframe-parquet/benchmark/Writer10GB.hs new file mode 100644 index 00000000..09be1cba --- /dev/null +++ b/dataframe-parquet/benchmark/Writer10GB.hs @@ -0,0 +1,49 @@ +module Main (main) where + +import Control.DeepSeq (NFData (rnf)) +import Criterion.Main (bench, defaultMain, envWithCleanup, whnfIO) +import DataFrame.IO.Parquet.Writer (writeParquet) +import DataFrame.Internal.DataFrame (DataFrame, forceDataFrame) +import DataFrame10GB (stressDataFrame) +import System.Directory (removeDirectoryRecursive) +import System.FilePath (()) +import System.IO.Temp (createTempDirectory, getCanonicalTemporaryDirectory) + +data BenchmarkEnvironment = BenchmarkEnvironment + { benchmarkDataFrame :: DataFrame + , benchmarkDirectory :: FilePath + , benchmarkOutput :: FilePath + } + +instance NFData BenchmarkEnvironment where + rnf environment = + forceDataFrame (benchmarkDataFrame environment) `seq` + rnf (benchmarkDirectory environment) `seq` + rnf (benchmarkOutput environment) + +prepareEnvironment :: IO BenchmarkEnvironment +prepareEnvironment = do + temporary <- getCanonicalTemporaryDirectory + directory <- createTempDirectory temporary "dataframe-parquet-writer-10gb" + pure + BenchmarkEnvironment + { benchmarkDataFrame = stressDataFrame + , benchmarkDirectory = directory + , benchmarkOutput = directory "benchmark.parquet" + } + +cleanupEnvironment :: BenchmarkEnvironment -> IO () +cleanupEnvironment = removeDirectoryRecursive . benchmarkDirectory + +main :: IO () +main = + defaultMain + [ envWithCleanup prepareEnvironment cleanupEnvironment $ \environment -> + -- Memory usage for this benchmark will be north of 20 GB. + bench "write 10 GiB dataframe" $ + whnfIO + ( writeParquet + (benchmarkOutput environment) + (benchmarkDataFrame environment) + ) + ] diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs index 422ad439..956e922e 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs @@ -79,7 +79,7 @@ import qualified Pinch -- of space. So how do we ensure that we both hit our page size target, our record size target, and have -- the same number of rows in each column? -- --- First we must consider the page size and row group sizes to be best effort. They could be slightly above +-- We must consider the page size and row group sizes to be best effort. They could be slightly above -- or below the target. The characteristics of the parquet file will depend on both the write options and -- the specific data being encoded. Arrow-rs runs batches of rows through the writer, flushing when -- they see that a page/rowgroup has met or exceeded its limit. @@ -107,6 +107,13 @@ import qualified Pinch -- We haven't yet implemented all the encodings and compressions possible. The writer should first -- be brought to parity with the reader, and then we should implement encodings and compressions in -- both together so neither lags behind the other. +-- +-- We also don't yet support a way to have different compressions/endodings per page, and I imagine +-- we would use some kind of heuristid to select these things, if we should want such a thing at all +-- +-- Repetition levels and Definitions levlels above 1 are also not yet supported, but that may come hand +-- in hand with bigger work where we work out the best way to support arbitraritly nested rows in +-- dataframe in a general way (as opposed to what we have today) writeParquet :: FilePath -> DataFrame -> IO () writeParquet = writeParquetWithOptions defaultParquetWriteOptions From ad47e3b51712634d39a0a465a919c8b33b6e2711 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 17:30:10 +0530 Subject: [PATCH 09/21] added benchmarks and a stress test --- dataframe-parquet/dataframe-parquet.cabal | 43 +++++++ dataframe-parquet/stress/DataFrame10GB.hs | 136 ++++++++++++++++++++++ dataframe-parquet/stress/StressMain.hs | 40 +++++++ 3 files changed, 219 insertions(+) create mode 100644 dataframe-parquet/stress/DataFrame10GB.hs create mode 100644 dataframe-parquet/stress/StressMain.hs diff --git a/dataframe-parquet/dataframe-parquet.cabal b/dataframe-parquet/dataframe-parquet.cabal index e7a9a987..91657636 100644 --- a/dataframe-parquet/dataframe-parquet.cabal +++ b/dataframe-parquet/dataframe-parquet.cabal @@ -28,6 +28,11 @@ common warnings -Wunused-local-binds -Wunused-packages +flag stress-tests + description: Build and run the opt-in 10 GiB Parquet roundtrip stress test. + default: False + manual: True + library import: warnings ghc-options: -O2 @@ -88,3 +93,41 @@ test-suite dataframe-parquet-tests temporary >= 1.3 && < 1.5, HUnit >= 1.6 && < 1.8 default-language: Haskell2010 + +executable dataframe-parquet-10gb-stress + import: warnings + main-is: StressMain.hs + other-modules: DataFrame10GB + hs-source-dirs: stress + if !flag(stress-tests) + buildable: False + build-depends: base >= 4 && < 5, + dataframe-core >= 2.4 && < 2.5, + dataframe-parquet, + filepath >= 1.4 && < 2, + temporary >= 1.3 && < 1.5, + text >= 2.1 && < 3, + time >= 1.12 && < 2, + vector >= 0.13 && < 0.15 + default-language: Haskell2010 + ghc-options: -O2 -prof -fprof-auto -threaded -rtsopts -with-rtsopts=-N + +benchmark dataframe-parquet-writer-10gb + import: warnings + type: exitcode-stdio-1.0 + main-is: Writer10GB.hs + other-modules: DataFrame10GB + hs-source-dirs: benchmark, stress + build-depends: base >= 4 && < 5, + criterion >= 1 && < 2, + deepseq >= 1.4 && < 2, + dataframe-core >= 2.4 && < 2.5, + dataframe-parquet, + directory >= 1.3 && < 2, + filepath >= 1.4 && < 2, + temporary >= 1.3 && < 1.5, + text >= 2.1 && < 3, + time >= 1.12 && < 2, + vector >= 0.13 && < 0.15 + default-language: Haskell2010 + ghc-options: -O2 -threaded -rtsopts -with-rtsopts=-N diff --git a/dataframe-parquet/stress/DataFrame10GB.hs b/dataframe-parquet/stress/DataFrame10GB.hs new file mode 100644 index 00000000..824c35e5 --- /dev/null +++ b/dataframe-parquet/stress/DataFrame10GB.hs @@ -0,0 +1,136 @@ +{-# LANGUAGE BangPatterns #-} +{-# LANGUAGE NumericUnderscores #-} + +module DataFrame10GB ( + stressDataFrame, + stressRows, + stressColumns, + stressResidentBytesLowerBound, +) where + +import Control.Monad.ST (runST) +import Data.Int (Int32, Int64) +import qualified Data.Text as T +import qualified Data.Text.Array as A +import Data.Time (UTCTime (UTCTime), addDays, fromGregorian, secondsToDiffTime) +import qualified Data.Vector as VB +import qualified Data.Vector.Unboxed as VU +import Data.Word (Word8) +import DataFrame.Internal.Column (Bitmap, Column (..)) +import DataFrame.Internal.DataFrame (DataFrame, fromNamedColumns) +import DataFrame.Internal.PackedText (mkPackedContiguous32) + +stressRows :: Int +stressRows = 1_000_000 + +stressGroups :: Int +stressGroups = 16 + +stressColumns :: Int +stressColumns = stressGroups * 14 + +textBytesPerRow :: Int +textBytesPerRow = 320 + +stressResidentBytesLowerBound :: Integer +stressResidentBytesLowerBound = + fromIntegral stressRows + * fromIntegral stressGroups + * fromIntegral (2 * textBytesPerRow + 2 * (4 + 8 + 4 + 8)) + +stressDataFrame :: DataFrame +stressDataFrame = fromNamedColumns (concatMap columnGroup [0 .. stressGroups - 1]) + +columnGroup :: Int -> [(T.Text, Column)] +columnGroup group = + [ named "int32" (UnboxedColumn Nothing (int32Values group)) + , named "int64" (UnboxedColumn Nothing (int64Values group)) + , named "float" (UnboxedColumn Nothing (floatValues group)) + , named "double" (UnboxedColumn Nothing (doubleValues group)) + , named "bool" (UnboxedColumn Nothing (boolValues group)) + , named "timestamp" (BoxedColumn Nothing (timestampValues group)) + , named "text" (textColumn Nothing group) + , named + "nullable_int32" + (UnboxedColumn (Just nullableBitmap) (int32Values (group + stressGroups))) + , named + "nullable_int64" + (UnboxedColumn (Just nullableBitmap) (int64Values (group + stressGroups))) + , named + "nullable_float" + (UnboxedColumn (Just nullableBitmap) (floatValues (group + stressGroups))) + , named + "nullable_double" + (UnboxedColumn (Just nullableBitmap) (doubleValues (group + stressGroups))) + , named + "nullable_bool" + (UnboxedColumn (Just nullableBitmap) (boolValues (group + stressGroups))) + , named + "nullable_timestamp" + (BoxedColumn (Just nullableBitmap) (timestampValues (group + stressGroups))) + , named "nullable_text" (textColumn (Just nullableBitmap) (group + stressGroups)) + ] + where + named suffix column = (T.pack ("group_" <> show group <> "_" <> suffix), column) + +nullableBitmap :: Bitmap +nullableBitmap = VU.replicate (stressRows `div` 8) (0xFE :: Word8) + +int32Values :: Int -> VU.Vector Int32 +int32Values salt = + VU.generate stressRows $ \row -> + fromIntegral ((row + salt * 10_007) `mod` 2_000_001 - 1_000_000) + +int64Values :: Int -> VU.Vector Int64 +int64Values salt = + VU.generate stressRows $ \row -> + fromIntegral row * 1_000_003 - fromIntegral salt * 10_000_019 + +floatValues :: Int -> VU.Vector Float +floatValues salt = + VU.generate stressRows $ \row -> + fromIntegral ((row + salt * 101) `mod` 100_003) / 17 + +doubleValues :: Int -> VU.Vector Double +doubleValues salt = + VU.generate stressRows $ \row -> + fromIntegral row / 31.0 - fromIntegral salt * 1_000.25 + +boolValues :: Int -> VU.Vector Bool +boolValues salt = VU.generate stressRows (\row -> (row + salt) `mod` 3 == 0) + +timestampValues :: Int -> VB.Vector UTCTime +timestampValues salt = + VB.replicate + stressRows + ( UTCTime + (addDays (fromIntegral salt) (fromGregorian 2020 1 1)) + (secondsToDiffTime (fromIntegral (salt * 1_337 `mod` 86_400))) + ) + +textColumn :: Maybe Bitmap -> Int -> Column +textColumn bitmap salt = PackedText bitmap $ runST $ do + target <- A.new (stressRows * textBytesPerRow) + let template = textTemplate salt + fill !row + | row >= stressRows = pure () + | otherwise = do + A.copyI textBytesPerRow target (row * textBytesPerRow) template 0 + fill (row + 1) + fill 0 + bytes <- A.unsafeFreeze target + let offsets = + VU.generate + (stressRows + 1) + (\row -> fromIntegral (row * textBytesPerRow) :: Int32) + pure (mkPackedContiguous32 bytes offsets) + +textTemplate :: Int -> A.Array +textTemplate salt = A.run $ do + bytes <- A.new textBytesPerRow + let byte = fromIntegral (97 + salt `mod` 26) + fill !index + | index >= textBytesPerRow = pure () + | otherwise = A.unsafeWrite bytes index byte >> fill (index + 1) + fill 0 + pure bytes diff --git a/dataframe-parquet/stress/StressMain.hs b/dataframe-parquet/stress/StressMain.hs new file mode 100644 index 00000000..c8cd65d4 --- /dev/null +++ b/dataframe-parquet/stress/StressMain.hs @@ -0,0 +1,40 @@ +{-# LANGUAGE NumericUnderscores #-} + +module Main (main) where + +import Control.Exception (evaluate) +import Control.Monad (unless) +import DataFrame.IO.Parquet (readParquet) +import DataFrame.IO.Parquet.Writer (writeParquet) +import DataFrame.Internal.DataFrame (forceDataFrame) +import DataFrame10GB ( + stressColumns, + stressDataFrame, + stressResidentBytesLowerBound, + stressRows, + ) +import System.Exit (exitFailure) +import System.FilePath (()) +import System.IO (hPutStrLn, stderr) +import System.IO.Temp (withSystemTempDirectory) + +main :: IO () +main = withSystemTempDirectory "dataframe-parquet-10gb-stress" $ \directory -> do + expected <- evaluate (forceDataFrame stressDataFrame) + let output = directory "roundtrip.parquet" + putStrLn + ( "writing " + <> show stressRows + <> " rows x " + <> show stressColumns + <> " columns (at least " + <> show stressResidentBytesLowerBound + <> " resident payload bytes)" + ) + writeParquet output expected + putStrLn "reading the stress dataframe" + actual <- readParquet output + putStrLn "checking dataframe equivalence" + unless (expected == actual) $ do + hPutStrLn stderr "10 GiB Parquet roundtrip mismatch" + exitFailure From 237a139684aada85e14d514cfa788f0bbcd6d6d1 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 17:48:33 +0530 Subject: [PATCH 10/21] Optimized hotspots --- .../src/DataFrame/IO/Parquet/Writer.hs | 5 +- .../IO/Parquet/Writer/ColumnChunkWriter.hs | 34 ++-- .../DataFrame/IO/Parquet/Writer/Encoder.hs | 170 +++++++++++++----- .../src/DataFrame/IO/Utils/RandomAccess.hs | 14 ++ 4 files changed, 161 insertions(+), 62 deletions(-) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs index 956e922e..932033e9 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs @@ -21,9 +21,8 @@ import DataFrame.IO.Parquet.Writer.ColumnChunkWriter ( bufferedSize, finalizePage, initColumnState, - maybeFinalizePage, runColumnChunkWriter, - writeRow, + writeRowAndMaybeFinalize, ) import DataFrame.IO.Parquet.Writer.Encoder (Encoder (..)) import DataFrame.IO.Parquet.Writer.Metadata (magic, rootSchemaElement) @@ -139,7 +138,7 @@ writeParquetWithOptions opts path df = do -- When a page is full (frome the page size writer option) flush it to its ColumnChunk -- When all the columnChunks combined match or exceed the row group size option -- flush all the columnchunks to file one by one - VB.forM_ cols (runColumnChunkWriter (writeRow row >> maybeFinalizePage opts)) + VB.forM_ cols (writeRowAndMaybeFinalize opts row) modifyIORef' rgRowsRef (+ 1) when ((row + 1) `mod` interval == 0) $ do size <- bufferedSize cols diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs index 59bc3a1c..2179d8b6 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs @@ -10,6 +10,7 @@ module DataFrame.IO.Parquet.Writer.ColumnChunkWriter ( askColumnChunk, initColumnState, writeRow, + writeRowAndMaybeFinalize, maybeFinalizePage, finalizePage, bufferedSize, @@ -19,11 +20,11 @@ import Control.Monad (when) import Control.Monad.IO.Class (MonadIO (..)) import qualified Data.ByteString as BS import Data.Int (Int64) -import Data.IORef (IORef, modifyIORef', newIORef, readIORef) +import Data.IORef (IORef, modifyIORef', newIORef) import qualified Data.Text as T import qualified Data.Vector as VB import DataFrame.IO.Parquet.Thrift -import DataFrame.IO.Parquet.Writer.DefLevels (DefLevels (..)) +import DataFrame.IO.Parquet.Writer.DefLevels (DefLevels (..), pushDef) import DataFrame.IO.Parquet.Writer.Encoder (Encoder (..), buildEncoder) import DataFrame.IO.Parquet.Writer.Metadata (mkDataPageHeader, mkSchemaElem) import DataFrame.IO.Parquet.Writer.Options (ParquetWriteOptions (..)) @@ -31,10 +32,8 @@ import DataFrame.IO.Parquet.Writer.PageWriter ( PageState (..), PageWriter (..), assemblePageBody, - bumpRows, newPageState, pageRows, - recordDef, resetPage, ) import DataFrame.IO.Utils.RandomAccess ( @@ -111,12 +110,25 @@ initColumnState opts name col = do } writeRow :: Int -> ColumnChunkWriter () -writeRow row = do - st <- askColumnChunk - present <- page (encWriteValue (ckEncoder st) row) - page $ do - when (ckNullable st) (recordDef present) - bumpRows +writeRow row = ColumnChunkWriter (writeRowIO row) + +writeRowIO :: Int -> ColumnChunkState -> IO () +writeRowIO row st = do + let pageState = ckPage st + present <- encWriteValue (ckEncoder st) pageState.psValues row + when (ckNullable st) (pushDef pageState.psDefs (if present then 1 else 0)) + modifyIORef' pageState.psRows (+ 1) +{-# INLINE writeRowIO #-} + +writeRowAndMaybeFinalize :: + ParquetWriteOptions -> Int -> ColumnChunkState -> IO () +writeRowAndMaybeFinalize opts row st = do + writeRowIO row st + size <- bufferResidency st.ckPage.psValues + when + (size >= opts.pageSize) + (runColumnChunkWriter (finalizePage opts.compressionCodec) st) +{-# INLINE writeRowAndMaybeFinalize #-} maybeFinalizePage :: ParquetWriteOptions -> ColumnChunkWriter () maybeFinalizePage opts = do @@ -128,7 +140,7 @@ finalizePage codec = do st <- askColumnChunk rows <- page pageRows when (rows > 0) $ do - page (encFinishValues (ckEncoder st)) + liftIO (encFinishValues (ckEncoder st) st.ckPage.psValues) body <- page (assemblePageBody (ckNullable st)) writeDataPage codec rows body page resetPage diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs index b2613af4..5f0cdd5f 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs @@ -12,10 +12,9 @@ module DataFrame.IO.Parquet.Writer.Encoder ( ) where import Control.Monad (when) -import Control.Monad.IO.Class (MonadIO (..)) import Data.Bits (shiftL, (.|.)) -import Data.Int (Int32, Int64) import Data.IORef (newIORef, readIORef, writeIORef) +import Data.Int (Int32, Int64) import qualified Data.Text as T import qualified Data.Text.Array as TA import Data.Text.Internal (Text (Text)) @@ -26,14 +25,14 @@ import qualified Data.Vector as VB import qualified Data.Vector.Unboxed as VU import Data.Word (Word8) import DataFrame.IO.Parquet.Thrift -import DataFrame.IO.Parquet.Writer.PageWriter (PageWriter) import DataFrame.IO.Utils.RandomAccess ( - putDoubleLE, - putFloatLE, - putGenerated, - putWord32LE, - putWord64LE, - putWord8, + MemoryBuffer, + appendTextArraySlice, + writeDoubleLE, + writeFloatLE, + writeWord32LE, + writeWord64LE, + writeWord8, ) import DataFrame.Internal.Column ( Bitmap, @@ -55,24 +54,37 @@ data Encoder = Encoder { encType :: !ThriftType , encConverted :: !(Maybe ConvertedType) , encLogical :: !(Maybe LogicalType) - , encWriteValue :: !(Int -> PageWriter Bool) -- Boolean for wat def levels should be (see pushDef) - , encFinishValues :: !(PageWriter ()) + , encWriteValue :: !(MemoryBuffer -> Int -> IO Bool) + , encFinishValues :: !(MemoryBuffer -> IO ()) } buildEncoder :: Column -> IO Encoder buildEncoder col | hasElemType @Int32 col = - pure $ scalarEncoder @Int32 (INT32 enum) Nothing Nothing (putWord32LE . fromIntegral) col + pure $ + scalarEncoder @Int32 + (INT32 enum) + Nothing + Nothing + (\buffer -> writeWord32LE buffer . fromIntegral) + col | hasElemType @Int64 col = - pure $ scalarEncoder @Int64 (INT64 enum) Nothing Nothing (putWord64LE . fromIntegral) col + pure $ + scalarEncoder @Int64 + (INT64 enum) + Nothing + Nothing + (\buffer -> writeWord64LE buffer . fromIntegral) + col | hasElemType @Float col = - pure $ scalarEncoder @Float (FLOAT enum) Nothing Nothing putFloatLE col + pure $ scalarEncoder @Float (FLOAT enum) Nothing Nothing writeFloatLE col | hasElemType @Double col = - pure $ scalarEncoder @Double (DOUBLE enum) Nothing Nothing putDoubleLE col + pure $ scalarEncoder @Double (DOUBLE enum) Nothing Nothing writeDoubleLE col | hasElemType @Bool col = boolEncoder col | hasElemType @T.Text col = pure (textEncoder col) | hasElemType @UTCTime col = pure (timestampEncoder col) - | otherwise = error ("writeParquet: unsupported column type " <> columnTypeString col) + | otherwise = + error ("writeParquet: unsupported column type " <> columnTypeString col) scalarEncoder :: forall a. @@ -80,19 +92,53 @@ scalarEncoder :: ThriftType -> Maybe ConvertedType -> Maybe LogicalType -> - (a -> PageWriter ()) -> + (MemoryBuffer -> a -> IO ()) -> Column -> Encoder scalarEncoder tt conv logical writeValue col = - Encoder tt conv logical (columnWriter @a col writeValue) (pure ()) + Encoder tt conv logical (columnWriter @a col writeValue) (const (pure ())) +{-# INLINEABLE scalarEncoder #-} +{-# SPECIALIZE scalarEncoder :: + ThriftType -> + Maybe ConvertedType -> + Maybe LogicalType -> + (MemoryBuffer -> Int32 -> IO ()) -> + Column -> + Encoder + #-} +{-# SPECIALIZE scalarEncoder :: + ThriftType -> + Maybe ConvertedType -> + Maybe LogicalType -> + (MemoryBuffer -> Int64 -> IO ()) -> + Column -> + Encoder + #-} +{-# SPECIALIZE scalarEncoder :: + ThriftType -> + Maybe ConvertedType -> + Maybe LogicalType -> + (MemoryBuffer -> Float -> IO ()) -> + Column -> + Encoder + #-} +{-# SPECIALIZE scalarEncoder :: + ThriftType -> + Maybe ConvertedType -> + Maybe LogicalType -> + (MemoryBuffer -> Double -> IO ()) -> + Column -> + Encoder + #-} columnWriter :: forall a. - Columnable a => + (Columnable a) => Column -> - (a -> PageWriter ()) -> + (MemoryBuffer -> a -> IO ()) -> + MemoryBuffer -> Int -> - PageWriter Bool + IO Bool columnWriter col writeValue = case col of BoxedColumn bitmap (values :: VB.Vector b) -> case testEquality (typeRep @a) (typeRep @b) of @@ -104,32 +150,56 @@ columnWriter col writeValue = case col of Nothing -> mismatch _ -> mismatch where - writeFrom bitmap at row - | isPresent bitmap row = writeValue (at row) >> pure True + writeFrom bitmap at buffer row + | isPresent bitmap row = writeValue buffer (at row) >> pure True | otherwise = pure False - mismatch = error ("writeParquet: incompatible column representation for " <> columnTypeString col) + mismatch = + error + ("writeParquet: incompatible column representation for " <> columnTypeString col) +{-# INLINEABLE columnWriter #-} +{-# SPECIALIZE columnWriter :: + Column -> (MemoryBuffer -> Int32 -> IO ()) -> MemoryBuffer -> Int -> IO Bool + #-} +{-# SPECIALIZE columnWriter :: + Column -> (MemoryBuffer -> Int64 -> IO ()) -> MemoryBuffer -> Int -> IO Bool + #-} +{-# SPECIALIZE columnWriter :: + Column -> (MemoryBuffer -> Float -> IO ()) -> MemoryBuffer -> Int -> IO Bool + #-} +{-# SPECIALIZE columnWriter :: + Column -> (MemoryBuffer -> Double -> IO ()) -> MemoryBuffer -> Int -> IO Bool + #-} +{-# SPECIALIZE columnWriter :: + Column -> (MemoryBuffer -> Bool -> IO ()) -> MemoryBuffer -> Int -> IO Bool + #-} +{-# SPECIALIZE columnWriter :: + Column -> (MemoryBuffer -> UTCTime -> IO ()) -> MemoryBuffer -> Int -> IO Bool + #-} isPresent :: Maybe Bitmap -> Int -> Bool isPresent Nothing _ = True isPresent (Just bitmap) row = bitmapTestBit bitmap row +{-# INLINE isPresent #-} boolEncoder :: Column -> IO Encoder boolEncoder col = do bitsRef <- newIORef (0 :: Word8) countRef <- newIORef (0 :: Int) - let addBit value = do - bits <- liftIO (readIORef bitsRef) - count <- liftIO (readIORef countRef) + let addBit buffer value = do + bits <- readIORef bitsRef + count <- readIORef countRef let bits' = if value then bits .|. ((1 :: Word8) `shiftL` count) else bits count' = count + 1 if count' == 8 - then putWord8 bits' >> liftIO (writeIORef bitsRef 0 >> writeIORef countRef 0) - else liftIO (writeIORef bitsRef bits' >> writeIORef countRef count') - finish = do - count <- liftIO (readIORef countRef) - when (count > 0) (liftIO (readIORef bitsRef) >>= putWord8) - liftIO (writeIORef bitsRef 0 >> writeIORef countRef 0) - pure (Encoder (BOOLEAN enum) Nothing Nothing (columnWriter @Bool col addBit) finish) + then writeWord8 buffer bits' >> writeIORef bitsRef 0 >> writeIORef countRef 0 + else writeIORef bitsRef bits' >> writeIORef countRef count' + finish buffer = do + count <- readIORef countRef + when (count > 0) (readIORef bitsRef >>= writeWord8 buffer) + writeIORef bitsRef 0 + writeIORef countRef 0 + pure + (Encoder (BOOLEAN enum) Nothing Nothing (columnWriter @Bool col addBit) finish) textEncoder :: Column -> Encoder textEncoder col = @@ -138,7 +208,7 @@ textEncoder col = (Just (UTF8 enum)) (Just (LT_STRING (putField StringType))) writePresent - (pure ()) + (const (pure ())) where writePresent = case col of BoxedColumn bitmap (values :: VB.Vector a) -> @@ -147,26 +217,30 @@ textEncoder col = Nothing -> mismatch PackedText bitmap packed -> writePacked bitmap packed _ -> mismatch - writeBoxed bitmap values row - | isPresent bitmap row = writeText (VB.unsafeIndex values row) >> pure True + writeBoxed bitmap values buffer row + | isPresent bitmap row = + writeText buffer (VB.unsafeIndex values row) >> pure True | otherwise = pure False - writePacked bitmap packed row + writePacked bitmap packed buffer row | isPresent bitmap row = do let baseRow = maybe row (\selection -> selAt selection row) packed.ptSel start = offAt packed.ptOffsets baseRow end = offAt packed.ptOffsets (baseRow + 1) - writeTextSlice packed.ptBytes start (end - start) + writeTextSlice buffer packed.ptBytes start (end - start) pure True | otherwise = pure False - mismatch = error ("writeParquet: incompatible text representation for " <> columnTypeString col) + mismatch = + error + ("writeParquet: incompatible text representation for " <> columnTypeString col) -writeText :: T.Text -> PageWriter () -writeText (Text bytes offset count) = writeTextSlice bytes offset count +writeText :: MemoryBuffer -> T.Text -> IO () +writeText buffer (Text bytes offset count) = writeTextSlice buffer bytes offset count -writeTextSlice :: TA.Array -> Int -> Int -> PageWriter () -writeTextSlice bytes offset count = do - putWord32LE (fromIntegral count) - putGenerated count (TA.unsafeIndex bytes . (+ offset)) +writeTextSlice :: MemoryBuffer -> TA.Array -> Int -> Int -> IO () +writeTextSlice buffer bytes offset count = do + writeWord32LE buffer (fromIntegral count) + appendTextArraySlice buffer bytes offset count +{-# INLINE writeTextSlice #-} timestampEncoder :: Column -> Encoder timestampEncoder col = @@ -175,9 +249,9 @@ timestampEncoder col = (Just (TIMESTAMP_MICROS enum)) (Just timestampLogical) (columnWriter @UTCTime col writeMicros) - (pure ()) + (const (pure ())) where - writeMicros t = putWord64LE (fromIntegral (utcToMicros t)) + writeMicros buffer t = writeWord64LE buffer (fromIntegral (utcToMicros t)) timestampLogical :: LogicalType timestampLogical = diff --git a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs index 9d9e562b..546cb2bb 100644 --- a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs +++ b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs @@ -22,6 +22,7 @@ module DataFrame.IO.Utils.RandomAccess ( withFileBuffer, appendByteString, appendGeneratedBytes, + appendTextArraySlice, appendByteStringHandle, writeWord8, writeWord32LE, @@ -47,10 +48,12 @@ module DataFrame.IO.Utils.RandomAccess ( ) where import Control.Monad.IO.Class (MonadIO (..)) +import Control.Monad.ST (stToIO) import Data.ByteString.Internal (ByteString (PS), create) import qualified Data.Foldable as Foldable import qualified Data.ByteString.Unsafe as BU import qualified Data.ByteString as BS +import qualified Data.Text.Array as TA import qualified Data.Vector.Storable as VS import Data.Word (Word8, Word32, Word64) import Data.Bits (shiftR) @@ -379,6 +382,17 @@ appendGeneratedBytes buffer count at go 0 writeIORef buffer.positionRef (position + count) +appendTextArraySlice :: MemoryBuffer -> TA.Array -> Int -> Int -> IO () +appendTextArraySlice buffer source offset count + | count < 0 = ioError $ userError "appendTextArraySlice: negative length" + | otherwise = do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + count) + withMutableByteArrayContents array $ \destination -> + stToIO (TA.copyToPointer source offset (destination `plusPtr` position) count) + writeIORef buffer.positionRef (position + count) +{-# INLINE appendTextArraySlice #-} + flushBufferToFile :: WritableBinaryHandle -> MemoryBuffer -> IO () flushBufferToFile handle = runReaderIO (flushTo (FileSink handle)) From 995fc7b4e8ff201f925c4f26827171b30f1166a8 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 20:35:08 +0530 Subject: [PATCH 11/21] ran fourmolu --- .../src/DataFrame/IO/Parquet/Writer.hs | 34 +- .../IO/Parquet/Writer/ColumnChunkWriter.hs | 20 +- .../DataFrame/IO/Parquet/Writer/Metadata.hs | 11 +- .../src/DataFrame/IO/Utils/RandomAccess.hs | 525 +++++++++--------- dataframe-parquet/tests/Main.hs | 47 +- 5 files changed, 345 insertions(+), 292 deletions(-) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs index 932033e9..552767f5 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs @@ -11,8 +11,8 @@ module DataFrame.IO.Parquet.Writer ( import Control.Monad (when) import qualified Data.ByteString as BS -import Data.Int (Int64) import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef) +import Data.Int (Int64) import Data.Maybe (fromJust) import qualified Data.Vector as VB import DataFrame.IO.Parquet.Thrift @@ -51,7 +51,7 @@ import DataFrame.Internal.DataFrame ( import Pinch (enum, putField) import qualified Pinch ---A Parquet file is a series of row groups followed by the file metadata (which contains the schema and the +-- A Parquet file is a series of row groups followed by the file metadata (which contains the schema and the -- metadata for all the rowgroups, which, in turn, contain the metadata for each column chunk). Inside each -- rowgroup is a series of column chunks. Column chunks consist of a series of pages. Pages are the PageHeader -- followed by RLE encoded definition levels (if they exist), RLE encoded repetition levels (if they exist), @@ -63,16 +63,16 @@ import qualified Pinch -- this that are discussed further below) -- -- We'll set the default Page size to 1 MiB and the default rowGroupSize to 128MiB (of course users will be --- able to adjust these numbers through write options). We need to hold the entire RowGroup in memory as +-- able to adjust these numbers through write options). We need to hold the entire RowGroup in memory as -- we build is as the ColumnChunks need to be contiguous when written to disk. So we need to hold -- buffers for each individual columnChunk as we go row by row and build them; the columnChunks cannot be --- interleaved. +-- interleaved. -- Since dataframe is columnar to begin with, we could, in theory, go golumn by column by estimating the size -- of a certain slice of a column, but I don't yet see a good way of doing this given we must run the gamut -- of encodings and compressions applied to each of those ColumnChunks (and those compression libraries have -- their own multifarious strategies for various kinds of data) --- +-- -- Each row group has to be a certain size, but each column in a row group must contain the same number -- of rows, even though each column may very well fit the same number of rows in very different amounts -- of space. So how do we ensure that we both hit our page size target, our record size target, and have @@ -81,7 +81,7 @@ import qualified Pinch -- We must consider the page size and row group sizes to be best effort. They could be slightly above -- or below the target. The characteristics of the parquet file will depend on both the write options and -- the specific data being encoded. Arrow-rs runs batches of rows through the writer, flushing when --- they see that a page/rowgroup has met or exceeded its limit. +-- they see that a page/rowgroup has met or exceeded its limit. -- -- So a row group is flushed specifically only on batch boundaries and we get the same number of rows in -- every row group except the last which will be smaller than the rest. They also use sub batching. so @@ -99,11 +99,11 @@ import qualified Pinch -- user chooses the two pass strategy anyway, the temp files will tend to be held in the OS Page Cache (RAM) -- anyway. -- --- Niceties like statistics and bloom filters and so on have not yet been implemented. We may need some +-- Niceties like statistics and bloom filters and so on have not yet been implemented. We may need some -- extra machinery to keep track of row ranges so we can use them with the dataframe to generate our -- statistics. -- --- We haven't yet implemented all the encodings and compressions possible. The writer should first +-- We haven't yet implemented all the encodings and compressions possible. The writer should first -- be brought to parity with the reader, and then we should implement encodings and compressions in -- both together so neither lags behind the other. -- @@ -123,7 +123,9 @@ writeParquetWithOptions opts path df = do error "writeParquet: TwoPass strategy is not yet implemented" let (nRows, _) = dataframeDimensions df names = columnNames df - cols <- VB.fromList <$> mapM (\n -> initColumnState opts n (fromJust (getColumn n df))) names + cols <- + VB.fromList + <$> mapM (\n -> initColumnState opts n (fromJust (getColumn n df))) names withWritableBinaryFile path $ \out -> do writeByteStringToFile out magic fileOff <- newIORef 4 @@ -163,7 +165,7 @@ finalizeRowGroup opts st = do VB.mapM_ (runColumnChunkWriter (finalizePage opts.compressionCodec)) st.wsCols (chunksRev, total) <- VB.foldM' - (\(acc, totalSize) cs -> do + ( \(acc, totalSize) cs -> do offset <- readIORef st.wsFileOffset size <- bufferResidency (ckBuffer cs) uncompressed <- readIORef (ckUncompressed cs) @@ -178,7 +180,14 @@ finalizeRowGroup opts st = do modifyIORef' st.wsRowGroups (mkRowGroup (reverse chunksRev) total rgRows :) writeIORef st.wsRgRows 0 -mkColumnChunk :: ParquetWriteOptions -> Int64 -> Int -> Int64 -> Int -> ColumnChunkState -> ColumnChunk +mkColumnChunk :: + ParquetWriteOptions -> + Int64 -> + Int -> + Int64 -> + Int -> + ColumnChunkState -> + ColumnChunk mkColumnChunk opts offset size uncompressed rgRows cs = ColumnChunk { cc_file_path = putField Nothing @@ -226,7 +235,8 @@ mkRowGroup chunks total rgRows = writeFooter :: WriterState -> Int -> IO () writeFooter st nRows = do rowGroups <- reverse <$> readIORef st.wsRowGroups - let schemaElements = rootSchemaElement (VB.length st.wsCols) : VB.toList (VB.map ckSchema st.wsCols) + let schemaElements = + rootSchemaElement (VB.length st.wsCols) : VB.toList (VB.map ckSchema st.wsCols) metadata = FileMetadata { version = putField 1 diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs index 2179d8b6..49af1cc9 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs @@ -19,8 +19,8 @@ module DataFrame.IO.Parquet.Writer.ColumnChunkWriter ( import Control.Monad (when) import Control.Monad.IO.Class (MonadIO (..)) import qualified Data.ByteString as BS -import Data.Int (Int64) import Data.IORef (IORef, modifyIORef', newIORef) +import Data.Int (Int64) import qualified Data.Text as T import qualified Data.Vector as VB import DataFrame.IO.Parquet.Thrift @@ -89,11 +89,18 @@ page (PageWriter f) = ColumnChunkWriter (f . ckPage) askColumnChunk :: ColumnChunkWriter ColumnChunkState askColumnChunk = ColumnChunkWriter pure -initColumnState :: ParquetWriteOptions -> T.Text -> Column -> IO ColumnChunkState +initColumnState :: + ParquetWriteOptions -> T.Text -> Column -> IO ColumnChunkState initColumnState opts name col = do encoder <- buildEncoder col let nullable = hasMissing col - schemaElem = mkSchemaElem name encoder.encType nullable encoder.encConverted encoder.encLogical + schemaElem = + mkSchemaElem + name + encoder.encType + nullable + encoder.encConverted + encoder.encLogical cap = max 1 opts.pageSize chunk <- mallocBuffer cap uncompressed <- newIORef 0 @@ -154,7 +161,10 @@ writeDataPage codec rows body = do compressed <- liftIO (Snappy.compress <$> bufferToByteString body) pure (BS.length compressed, putByteString compressed) other -> error ("writeParquet: unsupported codec " <> show other) - let headerBytes = Pinch.encode Pinch.compactProtocol (mkDataPageHeader rows uncompressedSize compressedSize) + let headerBytes = + Pinch.encode + Pinch.compactProtocol + (mkDataPageHeader rows uncompressedSize compressedSize) putByteString headerBytes emit bumpUncompressed (fromIntegral (BS.length headerBytes + uncompressedSize)) @@ -165,7 +175,7 @@ bumpUncompressed n = ColumnChunkWriter (\st -> modifyIORef' (ckUncompressed st) bufferedSize :: VB.Vector ColumnChunkState -> IO Int bufferedSize = VB.foldM' - (\total st -> do + ( \total st -> do chunk <- bufferResidency (ckBuffer st) values <- bufferResidency (psValues (ckPage st)) defs <- bufferResidency (ckPage st).psDefs.dlBuf diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Metadata.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Metadata.hs index b22dc91a..586632d6 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Metadata.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Metadata.hs @@ -34,12 +34,19 @@ mkDataPageHeader rows uncompressedSize compressedSize = , dph_statistics = putField Nothing } -mkSchemaElem :: T.Text -> ThriftType -> Bool -> Maybe ConvertedType -> Maybe LogicalType -> SchemaElement +mkSchemaElem :: + T.Text -> + ThriftType -> + Bool -> + Maybe ConvertedType -> + Maybe LogicalType -> + SchemaElement mkSchemaElem elementName elementType nullable converted logical = SchemaElement { schematype = putField (Just elementType) , type_length = putField Nothing - , repetition_type = putField (Just (if nullable then OPTIONAL enum else REQUIRED enum)) + , repetition_type = + putField (Just (if nullable then OPTIONAL enum else REQUIRED enum)) , name = putField elementName , num_children = putField Nothing , converted_type = putField converted diff --git a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs index 546cb2bb..81f30280 100644 --- a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs +++ b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs @@ -1,7 +1,7 @@ +{-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE TypeFamilies #-} -{-# LANGUAGE ConstraintKinds #-} module DataFrame.IO.Utils.RandomAccess ( uncurry3, @@ -47,26 +47,16 @@ module DataFrame.IO.Utils.RandomAccess ( copyBuffer, ) where +import Control.Exception (bracket) +import Control.Monad (foldM) import Control.Monad.IO.Class (MonadIO (..)) +import Control.Monad.Primitive (RealWorld) import Control.Monad.ST (stToIO) +import Data.Bits (shiftR) +import qualified Data.ByteString as BS import Data.ByteString.Internal (ByteString (PS), create) -import qualified Data.Foldable as Foldable import qualified Data.ByteString.Unsafe as BU -import qualified Data.ByteString as BS -import qualified Data.Text.Array as TA -import qualified Data.Vector.Storable as VS -import Data.Word (Word8, Word32, Word64) -import Data.Bits (shiftR) -import GHC.Float (castFloatToWord32, castDoubleToWord64) -import DataFrame.IO.Parquet.Seeking ( - FileBufferedOrSeekable, - fGet, - fSeek, - readLastBytes, - ) -import Control.Exception (bracket) -import Control.Monad (foldM) -import Control.Monad.Primitive (RealWorld) +import qualified Data.Foldable as Foldable import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.Primitive.ByteArray ( MutableByteArray, @@ -77,7 +67,17 @@ import Data.Primitive.ByteArray ( withMutableByteArrayContents, writeByteArray, ) +import qualified Data.Text.Array as TA +import qualified Data.Vector.Storable as VS +import Data.Word (Word32, Word64, Word8) +import DataFrame.IO.Parquet.Seeking ( + FileBufferedOrSeekable, + fGet, + fSeek, + readLastBytes, + ) import Foreign (castForeignPtr, castPtr, copyBytes, plusPtr) +import GHC.Float (castDoubleToWord64, castFloatToWord32) import System.IO ( BufferMode (NoBuffering), Handle, @@ -166,180 +166,180 @@ unsafeToByteString v = PS (castForeignPtr ptr) offset' len -- of cases we shouldn't be growing more than once, if that. See the docs for -- Data.Primitive.ByteArray.byteArrayContents. -newtype WritableBinaryHandle = WritableBinaryHandle { unHandle :: Handle } +newtype WritableBinaryHandle = WritableBinaryHandle {unHandle :: Handle} openWritableBinaryFile :: FilePath -> IO WritableBinaryHandle openWritableBinaryFile filepath = do - h <- openBinaryFile filepath AppendMode - hSetBinaryMode h True - hSetBuffering h NoBuffering - pure . WritableBinaryHandle $ h + h <- openBinaryFile filepath AppendMode + hSetBinaryMode h True + hSetBuffering h NoBuffering + pure . WritableBinaryHandle $ h withWritableBinaryFile :: FilePath -> (WritableBinaryHandle -> IO a) -> IO a withWritableBinaryFile filepath action = - bracket - (openWritableBinaryFile filepath) - (hClose . unHandle) - action + bracket + (openWritableBinaryFile filepath) + (hClose . unHandle) + action class (Monad m) => HasBuffer m where - type Buffer m - askBuffer :: m (Buffer m) - residency :: m Int -- number of bytes currently in the buffer - writeBytes :: (Foldable f) => f Word8 -> m () - flushTo :: Sink -> m () - + type Buffer m + askBuffer :: m (Buffer m) + residency :: m Int -- number of bytes currently in the buffer + writeBytes :: (Foldable f) => f Word8 -> m () + flushTo :: Sink -> m () + data MemoryBuffer = MemoryBuffer - { arrayRef :: !(IORef (MutableByteArray RealWorld)) - , positionRef :: !(IORef Int) - } + { arrayRef :: !(IORef (MutableByteArray RealWorld)) + , positionRef :: !(IORef Int) + } mallocBuffer :: Int -> IO MemoryBuffer mallocBuffer capacity - | capacity < 0 = ioError $ userError "mallocBuffer: negative capacity" - | otherwise = do - array <- newPinnedByteArray capacity - MemoryBuffer <$> newIORef array <*> newIORef 0 + | capacity < 0 = ioError $ userError "mallocBuffer: negative capacity" + | otherwise = do + array <- newPinnedByteArray capacity + MemoryBuffer <$> newIORef array <*> newIORef 0 data Sink = MemorySink MemoryBuffer | FileSink WritableBinaryHandle instance HasBuffer (ReaderIO MemoryBuffer) where - type Buffer (ReaderIO MemoryBuffer) = MemoryBuffer - - askBuffer = ReaderIO pure - - residency = ReaderIO $ \buffer -> readIORef buffer.positionRef - - writeBytes bytes = ReaderIO $ \buffer -> do - position <- readIORef buffer.positionRef - array <- ensureCapacity buffer (position + Foldable.length bytes) - newPosition <- foldM (\i byte -> writeByteArray array i byte >> pure (i + 1)) position bytes - writeIORef buffer.positionRef newPosition - - flushTo (MemorySink destination) = ReaderIO $ \source -> - if destination.arrayRef == source.arrayRef - then pure () - else do - sourceArray <- readIORef source.arrayRef - sourcePosition <- readIORef source.positionRef - destinationPosition <- readIORef destination.positionRef - let newDestinationPosition = destinationPosition + sourcePosition - destinationArray <- ensureCapacity destination newDestinationPosition - copyMutableByteArray - destinationArray - destinationPosition - sourceArray - 0 -- offset - sourcePosition -- number of bytes - writeIORef destination.positionRef newDestinationPosition - writeIORef source.positionRef 0 - - -- I tested write speeds by doing (on Apple Silicon) - -- `dd if=/dev/zero of=test bs={$n}k oflag=direct conv=fdatasync - -- Results: - -- - -- ``` - -- | block size | data (GiB) | time (s) | GiB/s | - -- |------------|------------|-----------|-------| - -- | 4k | 4.00 | 2.371 | 1.69 | - -- | 8k | 4.00 | 1.486 | 2.69 | - -- | 16k | 4.00 | 1.045 | 3.83 | - -- | 32k | 4.00 | 0.740 | 5.40 | - -- | 64k | 4.00 | 0.675 | 5.92 | - -- | 128k | 4.00 | 0.669 | 5.98 | - -- | 256k | 4.00 | 0.664 | 6.03 | - -- | 512k | 4.00 | 0.670 | 5.97 | - -- | 1024k | 4.00 | 0.664 | 6.02 | - -- | 4096k | 4.00 | 0.668 | 5.99 | - -- ``` - -- So when writing to a file to minimize syscall overhead while - -- trying not to create dirty pages in the kernel page cache, we'll - -- be flushing in 256 KiB chunks. - flushTo (FileSink (WritableBinaryHandle h)) = ReaderIO $ \buffer -> do - array <- readIORef buffer.arrayRef - position <- readIORef buffer.positionRef - withMutableByteArrayContents array $ \ptr -> do - let chunkSize = 262144 - go offset - | offset >= position = pure () - | otherwise = do - let n = min chunkSize (position - offset) - hPutBuf h (ptr `plusPtr` offset) n - go (offset + n) - go 0 - writeIORef buffer.positionRef 0 - --- We're using pinned ByteArrays so we must + type Buffer (ReaderIO MemoryBuffer) = MemoryBuffer + + askBuffer = ReaderIO pure + + residency = ReaderIO $ \buffer -> readIORef buffer.positionRef + + writeBytes bytes = ReaderIO $ \buffer -> do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + Foldable.length bytes) + newPosition <- + foldM (\i byte -> writeByteArray array i byte >> pure (i + 1)) position bytes + writeIORef buffer.positionRef newPosition + + flushTo (MemorySink destination) = ReaderIO $ \source -> + if destination.arrayRef == source.arrayRef + then pure () + else do + sourceArray <- readIORef source.arrayRef + sourcePosition <- readIORef source.positionRef + destinationPosition <- readIORef destination.positionRef + let newDestinationPosition = destinationPosition + sourcePosition + destinationArray <- ensureCapacity destination newDestinationPosition + copyMutableByteArray + destinationArray + destinationPosition + sourceArray + 0 -- offset + sourcePosition -- number of bytes + writeIORef destination.positionRef newDestinationPosition + writeIORef source.positionRef 0 + + -- I tested write speeds by doing (on Apple Silicon) + -- `dd if=/dev/zero of=test bs={$n}k oflag=direct conv=fdatasync + -- Results: + -- + -- ``` + -- | block size | data (GiB) | time (s) | GiB/s | + -- |------------|------------|-----------|-------| + -- | 4k | 4.00 | 2.371 | 1.69 | + -- | 8k | 4.00 | 1.486 | 2.69 | + -- | 16k | 4.00 | 1.045 | 3.83 | + -- | 32k | 4.00 | 0.740 | 5.40 | + -- | 64k | 4.00 | 0.675 | 5.92 | + -- | 128k | 4.00 | 0.669 | 5.98 | + -- | 256k | 4.00 | 0.664 | 6.03 | + -- | 512k | 4.00 | 0.670 | 5.97 | + -- | 1024k | 4.00 | 0.664 | 6.02 | + -- | 4096k | 4.00 | 0.668 | 5.99 | + -- ``` + -- So when writing to a file to minimize syscall overhead while + -- trying not to create dirty pages in the kernel page cache, we'll + -- be flushing in 256 KiB chunks. + flushTo (FileSink (WritableBinaryHandle h)) = ReaderIO $ \buffer -> do + array <- readIORef buffer.arrayRef + position <- readIORef buffer.positionRef + withMutableByteArrayContents array $ \ptr -> do + let chunkSize = 262144 + go offset + | offset >= position = pure () + | otherwise = do + let n = min chunkSize (position - offset) + hPutBuf h (ptr `plusPtr` offset) n + go (offset + n) + go 0 + writeIORef buffer.positionRef 0 + +-- We're using pinned ByteArrays so we must -- not use the grow fuynction brovided by primitive -- instead we must alloocatie a new pinned byteArray. -- We might have been worried about heap fragmentation -- becasue a single pinned object in a 4KB GHC block can -- keep the whole plock alive but oyr buffers will tend to --- be much larger than that. --- But the memory useage will temporarily spike to 2.5x the size of +-- be much larger than that. +-- But the memory useage will temporarily spike to 2.5x the size of -- the buffer, but it should be fine since the current writer is single threaded -- and grows *should* be rare (we also allocate a little extra space to -- begin with). -- If it becomes an issue we should start tracking an array of pointers --- to buffers intsead of replacing them wholesale so grwoing a buffer --- is just a matter of adding a new buffer to the array (which we can +-- to buffers intsead of replacing them wholesale so grwoing a buffer +-- is just a matter of adding a new buffer to the array (which we can -- pre-allocate to three elements to begin with and grow it only on the --- off chance that a buffer required more than three grows). The extra +-- off chance that a buffer required more than three grows). The extra -- ceremony of handling writes and flushes can be encapsulated well in -- HasBuffer instances. ensureCapacity :: MemoryBuffer -> Int -> IO (MutableByteArray RealWorld) ensureCapacity buffer needed = do - array <- readIORef buffer.arrayRef - maxSize <- getSizeofMutableByteArray array - if needed <= maxSize - then pure array - else do - position <- readIORef buffer.positionRef - grown <- newPinnedByteArray (needed + (needed `div` 2)) - copyMutableByteArray grown 0 array 0 position - writeIORef buffer.arrayRef grown - pure grown - + array <- readIORef buffer.arrayRef + maxSize <- getSizeofMutableByteArray array + if needed <= maxSize + then pure array + else do + position <- readIORef buffer.positionRef + grown <- newPinnedByteArray (needed + (needed `div` 2)) + copyMutableByteArray grown 0 array 0 position + writeIORef buffer.arrayRef grown + pure grown appendByteString :: MemoryBuffer -> ByteString -> IO () appendByteString buffer bs = - BU.unsafeUseAsCStringLen bs $ \(source, len) -> do - position <- readIORef buffer.positionRef - array <- ensureCapacity buffer (position + len) - withMutableByteArrayContents array $ \dst -> - copyBytes (dst `plusPtr` position) (castPtr source) len - writeIORef buffer.positionRef (position + len) + BU.unsafeUseAsCStringLen bs $ \(source, len) -> do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + len) + withMutableByteArrayContents array $ \dst -> + copyBytes (dst `plusPtr` position) (castPtr source) len + writeIORef buffer.positionRef (position + len) writeWord8 :: MemoryBuffer -> Word8 -> IO () writeWord8 buffer b = do - position <- readIORef buffer.positionRef - array <- ensureCapacity buffer (position + 1) - writeByteArray array position b - writeIORef buffer.positionRef (position + 1) + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + 1) + writeByteArray array position b + writeIORef buffer.positionRef (position + 1) writeWord32LE :: MemoryBuffer -> Word32 -> IO () writeWord32LE buffer w = do - position <- readIORef buffer.positionRef - array <- ensureCapacity buffer (position + 4) - writeByteArray array position (fromIntegral w :: Word8) - writeByteArray array (position + 1) (fromIntegral (w `shiftR` 8) :: Word8) - writeByteArray array (position + 2) (fromIntegral (w `shiftR` 16) :: Word8) - writeByteArray array (position + 3) (fromIntegral (w `shiftR` 24) :: Word8) - writeIORef buffer.positionRef (position + 4) + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + 4) + writeByteArray array position (fromIntegral w :: Word8) + writeByteArray array (position + 1) (fromIntegral (w `shiftR` 8) :: Word8) + writeByteArray array (position + 2) (fromIntegral (w `shiftR` 16) :: Word8) + writeByteArray array (position + 3) (fromIntegral (w `shiftR` 24) :: Word8) + writeIORef buffer.positionRef (position + 4) writeWord64LE :: MemoryBuffer -> Word64 -> IO () writeWord64LE buffer w = do - position <- readIORef buffer.positionRef - array <- ensureCapacity buffer (position + 8) - writeByteArray array position (fromIntegral w :: Word8) - writeByteArray array (position + 1) (fromIntegral (w `shiftR` 8) :: Word8) - writeByteArray array (position + 2) (fromIntegral (w `shiftR` 16) :: Word8) - writeByteArray array (position + 3) (fromIntegral (w `shiftR` 24) :: Word8) - writeByteArray array (position + 4) (fromIntegral (w `shiftR` 32) :: Word8) - writeByteArray array (position + 5) (fromIntegral (w `shiftR` 40) :: Word8) - writeByteArray array (position + 6) (fromIntegral (w `shiftR` 48) :: Word8) - writeByteArray array (position + 7) (fromIntegral (w `shiftR` 56) :: Word8) - writeIORef buffer.positionRef (position + 8) + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + 8) + writeByteArray array position (fromIntegral w :: Word8) + writeByteArray array (position + 1) (fromIntegral (w `shiftR` 8) :: Word8) + writeByteArray array (position + 2) (fromIntegral (w `shiftR` 16) :: Word8) + writeByteArray array (position + 3) (fromIntegral (w `shiftR` 24) :: Word8) + writeByteArray array (position + 4) (fromIntegral (w `shiftR` 32) :: Word8) + writeByteArray array (position + 5) (fromIntegral (w `shiftR` 40) :: Word8) + writeByteArray array (position + 6) (fromIntegral (w `shiftR` 48) :: Word8) + writeByteArray array (position + 7) (fromIntegral (w `shiftR` 56) :: Word8) + writeIORef buffer.positionRef (position + 8) writeFloatLE :: MemoryBuffer -> Float -> IO () writeFloatLE buffer = writeWord32LE buffer . castFloatToWord32 @@ -349,20 +349,26 @@ writeDoubleLE buffer = writeWord64LE buffer . castDoubleToWord64 copyBufferInto :: MemoryBuffer -> MemoryBuffer -> IO () copyBufferInto destination source = do - sourceArray <- readIORef source.arrayRef - sourcePosition <- readIORef source.positionRef - destinationPosition <- readIORef destination.positionRef - destinationArray <- ensureCapacity destination (destinationPosition + sourcePosition) - copyMutableByteArray destinationArray destinationPosition sourceArray 0 sourcePosition - writeIORef destination.positionRef (destinationPosition + sourcePosition) + sourceArray <- readIORef source.arrayRef + sourcePosition <- readIORef source.positionRef + destinationPosition <- readIORef destination.positionRef + destinationArray <- + ensureCapacity destination (destinationPosition + sourcePosition) + copyMutableByteArray + destinationArray + destinationPosition + sourceArray + 0 + sourcePosition + writeIORef destination.positionRef (destinationPosition + sourcePosition) bufferToByteString :: MemoryBuffer -> IO ByteString bufferToByteString buffer = do - array <- readIORef buffer.arrayRef - position <- readIORef buffer.positionRef - create position $ \dst -> - withMutableByteArrayContents array $ \src -> - copyBytes dst (castPtr src) position + array <- readIORef buffer.arrayRef + position <- readIORef buffer.positionRef + create position $ \dst -> + withMutableByteArrayContents array $ \src -> + copyBytes dst (castPtr src) position bufferResidency :: MemoryBuffer -> IO Int bufferResidency buffer = readIORef buffer.positionRef @@ -372,25 +378,25 @@ resetPosition buffer = writeIORef buffer.positionRef 0 appendGeneratedBytes :: MemoryBuffer -> Int -> (Int -> Word8) -> IO () appendGeneratedBytes buffer count at - | count < 0 = ioError $ userError "appendGeneratedBytes: negative length" - | otherwise = do - position <- readIORef buffer.positionRef - array <- ensureCapacity buffer (position + count) - let go i - | i >= count = pure () - | otherwise = writeByteArray array (position + i) (at i) >> go (i + 1) - go 0 - writeIORef buffer.positionRef (position + count) + | count < 0 = ioError $ userError "appendGeneratedBytes: negative length" + | otherwise = do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + count) + let go i + | i >= count = pure () + | otherwise = writeByteArray array (position + i) (at i) >> go (i + 1) + go 0 + writeIORef buffer.positionRef (position + count) appendTextArraySlice :: MemoryBuffer -> TA.Array -> Int -> Int -> IO () appendTextArraySlice buffer source offset count - | count < 0 = ioError $ userError "appendTextArraySlice: negative length" - | otherwise = do - position <- readIORef buffer.positionRef - array <- ensureCapacity buffer (position + count) - withMutableByteArrayContents array $ \destination -> - stToIO (TA.copyToPointer source offset (destination `plusPtr` position) count) - writeIORef buffer.positionRef (position + count) + | count < 0 = ioError $ userError "appendTextArraySlice: negative length" + | otherwise = do + position <- readIORef buffer.positionRef + array <- ensureCapacity buffer (position + count) + withMutableByteArrayContents array $ \destination -> + stToIO (TA.copyToPointer source offset (destination `plusPtr` position) count) + writeIORef buffer.positionRef (position + count) {-# INLINE appendTextArraySlice #-} flushBufferToFile :: WritableBinaryHandle -> MemoryBuffer -> IO () @@ -398,120 +404,117 @@ flushBufferToFile handle = runReaderIO (flushTo (FileSink handle)) writeByteStringToFile :: WritableBinaryHandle -> ByteString -> IO () writeByteStringToFile handle bs = do - buffer <- mallocBuffer (max 1 (BS.length bs)) - appendByteString buffer bs - flushBufferToFile handle buffer + buffer <- mallocBuffer (max 1 (BS.length bs)) + appendByteString buffer bs + flushBufferToFile handle buffer type MemoryWriter m = (HasBuffer m, Buffer m ~ MemoryBuffer, MonadIO m) -onBuffer :: MonadIO m => MemoryBuffer -> ReaderIO MemoryBuffer a -> m a +onBuffer :: (MonadIO m) => MemoryBuffer -> ReaderIO MemoryBuffer a -> m a onBuffer buffer action = liftIO (runReaderIO action buffer) -putWord8 :: MemoryWriter m => Word8 -> m () +putWord8 :: (MemoryWriter m) => Word8 -> m () putWord8 value = askBuffer >>= \buffer -> liftIO (writeWord8 buffer value) -putWord32LE :: MemoryWriter m => Word32 -> m () +putWord32LE :: (MemoryWriter m) => Word32 -> m () putWord32LE value = askBuffer >>= \buffer -> liftIO (writeWord32LE buffer value) -putWord64LE :: MemoryWriter m => Word64 -> m () +putWord64LE :: (MemoryWriter m) => Word64 -> m () putWord64LE value = askBuffer >>= \buffer -> liftIO (writeWord64LE buffer value) -putFloatLE :: MemoryWriter m => Float -> m () +putFloatLE :: (MemoryWriter m) => Float -> m () putFloatLE value = askBuffer >>= \buffer -> liftIO (writeFloatLE buffer value) -putDoubleLE :: MemoryWriter m => Double -> m () +putDoubleLE :: (MemoryWriter m) => Double -> m () putDoubleLE value = askBuffer >>= \buffer -> liftIO (writeDoubleLE buffer value) -putByteString :: MemoryWriter m => ByteString -> m () +putByteString :: (MemoryWriter m) => ByteString -> m () putByteString bytes = askBuffer >>= \buffer -> liftIO (appendByteString buffer bytes) -putGenerated :: MemoryWriter m => Int -> (Int -> Word8) -> m () +putGenerated :: (MemoryWriter m) => Int -> (Int -> Word8) -> m () putGenerated count at = askBuffer >>= \buffer -> liftIO (appendGeneratedBytes buffer count at) -copyBuffer :: MemoryWriter m => MemoryBuffer -> m () +copyBuffer :: (MemoryWriter m) => MemoryBuffer -> m () copyBuffer source = askBuffer >>= \destination -> liftIO (copyBufferInto destination source) data BufferHandle = BufferHandle - { bufferPath :: !FilePath - , bufferHandle :: !WritableBinaryHandle - , residencyRef :: !(IORef Int) - , flushedRef :: !(IORef Int) - } + { bufferPath :: !FilePath + , bufferHandle :: !WritableBinaryHandle + , residencyRef :: !(IORef Int) + , flushedRef :: !(IORef Int) + } withFileBuffer :: FilePath -> (BufferHandle -> IO a) -> IO a withFileBuffer filepath action = - bracket open (hClose . unHandle . bufferHandle) action + bracket open (hClose . unHandle . bufferHandle) action where open = do - h <- openBinaryFile filepath ReadWriteMode - hSetBinaryMode h True - hSetBuffering h NoBuffering - res <- newIORef 0 - flushed <- newIORef 0 - pure (BufferHandle filepath (WritableBinaryHandle h) res flushed) + h <- openBinaryFile filepath ReadWriteMode + hSetBinaryMode h True + hSetBuffering h NoBuffering + res <- newIORef 0 + flushed <- newIORef 0 + pure (BufferHandle filepath (WritableBinaryHandle h) res flushed) instance HasBuffer (ReaderIO BufferHandle) where - type Buffer (ReaderIO BufferHandle) = BufferHandle - - askBuffer = ReaderIO pure - - residency = ReaderIO $ \bh -> readIORef bh.residencyRef - - writeBytes bytes = ReaderIO $ \bh -> do - let WritableBinaryHandle h = bh.bufferHandle - scratch <- newPinnedByteArray 1 - n <- - foldM - ( \count byte -> do - writeByteArray scratch 0 byte - hPutBuf h (mutableByteArrayContents scratch) 1 - pure (count + 1) - ) - 0 - bytes - position <- readIORef bh.residencyRef - writeIORef bh.residencyRef (position + n) - - -- Flushing from a file to a sink happens using - -- a reusable 256 KiB buffer closed over this function - -- Usually for this instance we shoulc be diong only - -- file to file but file to buffer is also bossible - -- if you should want to do that, for whatever reason - -- (don't let me tell you how to live your life) - flushTo sink = ReaderIO $ \bh -> do - count <- readIORef bh.residencyRef - offset <- readIORef bh.flushedRef - let WritableBinaryHandle h = bh.bufferHandle - chunkSize = 262144 - chunk <- newPinnedByteArray (min chunkSize count) - let ptr = mutableByteArrayContents chunk - pushChunk n = case sink of - FileSink (WritableBinaryHandle out) -> hPutBuf out ptr n - MemorySink dest -> do - destinationPosition <- readIORef dest.positionRef - let newDestinationPosition = destinationPosition + n - destinationArray <- ensureCapacity dest newDestinationPosition - copyMutableByteArray destinationArray destinationPosition chunk 0 n - writeIORef dest.positionRef newDestinationPosition - go remaining - | remaining <= 0 = pure () - | otherwise = do - actual <- hGetBuf h ptr (min chunkSize remaining) - if actual <= 0 - then pure () - else pushChunk actual >> go (remaining - actual) - hSeek h AbsoluteSeek (fromIntegral offset) - go count - writeIORef bh.residencyRef 0 - writeIORef bh.flushedRef (offset + count) - - - + type Buffer (ReaderIO BufferHandle) = BufferHandle + + askBuffer = ReaderIO pure + + residency = ReaderIO $ \bh -> readIORef bh.residencyRef + + writeBytes bytes = ReaderIO $ \bh -> do + let WritableBinaryHandle h = bh.bufferHandle + scratch <- newPinnedByteArray 1 + n <- + foldM + ( \count byte -> do + writeByteArray scratch 0 byte + hPutBuf h (mutableByteArrayContents scratch) 1 + pure (count + 1) + ) + 0 + bytes + position <- readIORef bh.residencyRef + writeIORef bh.residencyRef (position + n) + + -- Flushing from a file to a sink happens using + -- a reusable 256 KiB buffer closed over this function + -- Usually for this instance we shoulc be diong only + -- file to file but file to buffer is also bossible + -- if you should want to do that, for whatever reason + -- (don't let me tell you how to live your life) + flushTo sink = ReaderIO $ \bh -> do + count <- readIORef bh.residencyRef + offset <- readIORef bh.flushedRef + let WritableBinaryHandle h = bh.bufferHandle + chunkSize = 262144 + chunk <- newPinnedByteArray (min chunkSize count) + let ptr = mutableByteArrayContents chunk + pushChunk n = case sink of + FileSink (WritableBinaryHandle out) -> hPutBuf out ptr n + MemorySink dest -> do + destinationPosition <- readIORef dest.positionRef + let newDestinationPosition = destinationPosition + n + destinationArray <- ensureCapacity dest newDestinationPosition + copyMutableByteArray destinationArray destinationPosition chunk 0 n + writeIORef dest.positionRef newDestinationPosition + go remaining + | remaining <= 0 = pure () + | otherwise = do + actual <- hGetBuf h ptr (min chunkSize remaining) + if actual <= 0 + then pure () + else pushChunk actual >> go (remaining - actual) + hSeek h AbsoluteSeek (fromIntegral offset) + go count + writeIORef bh.residencyRef 0 + writeIORef bh.flushedRef (offset + count) appendByteStringHandle :: BufferHandle -> ByteString -> IO () appendByteStringHandle bh bs = - BU.unsafeUseAsCStringLen bs $ \(source, len) -> do - let WritableBinaryHandle h = bh.bufferHandle - hPutBuf h source len - position <- readIORef bh.residencyRef - writeIORef bh.residencyRef (position + len) + BU.unsafeUseAsCStringLen bs $ \(source, len) -> do + let WritableBinaryHandle h = bh.bufferHandle + hPutBuf h source len + position <- readIORef bh.residencyRef + writeIORef bh.residencyRef (position + len) diff --git a/dataframe-parquet/tests/Main.hs b/dataframe-parquet/tests/Main.hs index 398de7b0..efec900a 100644 --- a/dataframe-parquet/tests/Main.hs +++ b/dataframe-parquet/tests/Main.hs @@ -1,7 +1,6 @@ {-# LANGUAGE ScopedTypeVariables #-} -- | Tests for the writer-buffer logic in "DataFrame.IO.Utils.RandomAccess". - module Main where import Control.Monad (forM) @@ -9,12 +8,11 @@ import qualified Data.ByteString as BS import Data.IORef (readIORef) import Data.Primitive.ByteArray (readByteArray) import Data.Word (Word8) -import System.FilePath (()) import qualified System.Exit as Exit +import System.FilePath (()) import System.IO.Temp (withSystemTempDirectory) import Test.HUnit -import DataFrame.IO.Utils.RandomAccess import DataFrame.IO.Parquet (readParquet) import DataFrame.IO.Parquet.Writer ( ParquetWriteOptions (..), @@ -22,6 +20,7 @@ import DataFrame.IO.Parquet.Writer ( writeParquet, writeParquetWithOptions, ) +import DataFrame.IO.Utils.RandomAccess withTempFileBuffer :: FilePath -> String -> (BufferHandle -> IO a) -> IO a withTempFileBuffer dir name = withFileBuffer (dir name) @@ -285,15 +284,39 @@ tests = , TestLabel "memory buffer: flush empties for reuse" memFlushEmptiesForReuse , TestLabel "memory buffer: self-flush is a no-op" memSelfFlushIsNoop , TestLabel "memory buffer: flush large payload" memFlushLargePayload - , TestLabel "writer roundtrip: alltypes_plain" (writerRoundTrip "alltypes_plain" "tests/data/alltypes_plain.parquet") - , TestLabel "writer roundtrip: alltypes_plain.snappy" (writerRoundTrip "alltypes_plain.snappy" "tests/data/alltypes_plain.snappy.parquet") - , TestLabel "writer roundtrip: alltypes_dictionary" (writerRoundTrip "alltypes_dictionary" "tests/data/alltypes_dictionary.parquet") - , TestLabel "writer roundtrip: alltypes_tiny_pages" (writerRoundTrip "alltypes_tiny_pages" "tests/data/alltypes_tiny_pages.parquet") - , TestLabel "writer roundtrip: transactions" (writerRoundTrip "transactions" "tests/data/transactions.parquet") - , TestLabel "writer roundtrip: mtcars" (writerRoundTrip "mtcars" "tests/data/mtcars.parquet") - , TestLabel "writer roundtrip: int32_decimal" (writerRoundTrip "int32_decimal" "tests/data/int32_decimal.parquet") - , TestLabel "writer roundtrip: int64_decimal" (writerRoundTrip "int64_decimal" "tests/data/int64_decimal.parquet") - , TestLabel "writer roundtrip: alltypes_plain multi-page" (writerRoundTripTiny "alltypes_plain multi-page" "tests/data/alltypes_plain.parquet") + , TestLabel + "writer roundtrip: alltypes_plain" + (writerRoundTrip "alltypes_plain" "tests/data/alltypes_plain.parquet") + , TestLabel + "writer roundtrip: alltypes_plain.snappy" + ( writerRoundTrip + "alltypes_plain.snappy" + "tests/data/alltypes_plain.snappy.parquet" + ) + , TestLabel + "writer roundtrip: alltypes_dictionary" + (writerRoundTrip "alltypes_dictionary" "tests/data/alltypes_dictionary.parquet") + , TestLabel + "writer roundtrip: alltypes_tiny_pages" + (writerRoundTrip "alltypes_tiny_pages" "tests/data/alltypes_tiny_pages.parquet") + , TestLabel + "writer roundtrip: transactions" + (writerRoundTrip "transactions" "tests/data/transactions.parquet") + , TestLabel + "writer roundtrip: mtcars" + (writerRoundTrip "mtcars" "tests/data/mtcars.parquet") + , TestLabel + "writer roundtrip: int32_decimal" + (writerRoundTrip "int32_decimal" "tests/data/int32_decimal.parquet") + , TestLabel + "writer roundtrip: int64_decimal" + (writerRoundTrip "int64_decimal" "tests/data/int64_decimal.parquet") + , TestLabel + "writer roundtrip: alltypes_plain multi-page" + ( writerRoundTripTiny + "alltypes_plain multi-page" + "tests/data/alltypes_plain.parquet" + ) ] main :: IO () From 74ff8792c266a70021843a3f9c756ee0a55ec23f Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 20:40:20 +0530 Subject: [PATCH 12/21] hlint --- .../IO/Parquet/Writer/ColumnChunkWriter.hs | 4 +- .../DataFrame/IO/Parquet/Writer/Encoder.hs | 3 +- .../DataFrame/IO/Parquet/Writer/PageWriter.hs | 4 +- .../src/DataFrame/IO/Parquet/parquet.thrift | 1486 +++++++++++++++++ .../src/DataFrame/IO/Utils/RandomAccess.hs | 7 +- dataframe-parquet/stress/StressMain.hs | 1 - 6 files changed, 1494 insertions(+), 11 deletions(-) create mode 100644 dataframe-parquet/src/DataFrame/IO/Parquet/parquet.thrift diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs index 49af1cc9..71469152 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs @@ -80,8 +80,8 @@ instance HasBuffer ColumnChunkWriter where type Buffer ColumnChunkWriter = MemoryBuffer askBuffer = ColumnChunkWriter (pure . ckBuffer) residency = ColumnChunkWriter (bufferResidency . ckBuffer) - writeBytes bytes = ColumnChunkWriter (\cs -> runReaderIO (writeBytes bytes) (ckBuffer cs)) - flushTo sink = ColumnChunkWriter (\cs -> runReaderIO (flushTo sink) (ckBuffer cs)) + writeBytes bytes = ColumnChunkWriter (runReaderIO (writeBytes bytes) . ckBuffer) + flushTo sink = ColumnChunkWriter (runReaderIO (flushTo sink) . ckBuffer) page :: PageWriter a -> ColumnChunkWriter a page (PageWriter f) = ColumnChunkWriter (f . ckPage) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs index 5f0cdd5f..db406bce 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/Encoder.hs @@ -4,7 +4,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -{-# LANGUAGE TypeOperators #-} module DataFrame.IO.Parquet.Writer.Encoder ( Encoder (..), @@ -223,7 +222,7 @@ textEncoder col = | otherwise = pure False writePacked bitmap packed buffer row | isPresent bitmap row = do - let baseRow = maybe row (\selection -> selAt selection row) packed.ptSel + let baseRow = maybe row (`selAt` row) packed.ptSel start = offAt packed.ptOffsets baseRow end = offAt packed.ptOffsets (baseRow + 1) writeTextSlice buffer packed.ptBytes start (end - start) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/PageWriter.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/PageWriter.hs index 66eaab64..1ced3c34 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/PageWriter.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/PageWriter.hs @@ -77,8 +77,8 @@ instance HasBuffer PageWriter where type Buffer PageWriter = MemoryBuffer askBuffer = PageWriter (pure . psValues) residency = PageWriter (bufferResidency . psValues) - writeBytes bytes = PageWriter (\ps -> runReaderIO (writeBytes bytes) (psValues ps)) - flushTo sink = PageWriter (\ps -> runReaderIO (flushTo sink) (psValues ps)) + writeBytes bytes = PageWriter (runReaderIO (writeBytes bytes) . psValues) + flushTo sink = PageWriter (runReaderIO (flushTo sink) . psValues) askPage :: PageWriter PageState askPage = PageWriter pure diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/parquet.thrift b/dataframe-parquet/src/DataFrame/IO/Parquet/parquet.thrift new file mode 100644 index 00000000..71f0b6e6 --- /dev/null +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/parquet.thrift @@ -0,0 +1,1486 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * File format description for the parquet file format + */ +namespace cpp parquet +namespace java org.apache.parquet.format + +/** + * Types supported by Parquet. These types are intended to be used in combination + * with the encodings to control the on disk storage format. + * For example INT16 is not included as a type since a good encoding of INT32 + * would handle this. + */ +enum Type { + BOOLEAN = 0; + INT32 = 1; + INT64 = 2; + INT96 = 3; // deprecated, new Parquet writers should not write data in INT96 + FLOAT = 4; + DOUBLE = 5; + BYTE_ARRAY = 6; + FIXED_LEN_BYTE_ARRAY = 7; +} + +/** + * DEPRECATED: Common types used by frameworks (e.g. Hive, Pig) using parquet. + * ConvertedType is superseded by LogicalType. This enum should not be extended. + * + * See LogicalTypes.md for conversion between ConvertedType and LogicalType. + */ +enum ConvertedType { + /** a BYTE_ARRAY actually contains UTF8 encoded chars */ + UTF8 = 0; + + /** a map is converted as an optional field containing a repeated key/value pair */ + MAP = 1; + + /** a key/value pair is converted into a group of two fields */ + MAP_KEY_VALUE = 2; + + /** a list is converted into an optional field containing a repeated field for its + * values */ + LIST = 3; + + /** an enum is converted into a BYTE_ARRAY field */ + ENUM = 4; + + /** + * A decimal value. + * + * This may be used to annotate BYTE_ARRAY or FIXED_LEN_BYTE_ARRAY primitive + * types. The underlying byte array stores the unscaled value encoded as two's + * complement using big-endian byte order (the most significant byte is the + * zeroth element). The value of the decimal is the value * 10^{-scale}. + * + * This must be accompanied by a (maximum) precision and a scale in the + * SchemaElement. The precision specifies the number of digits in the decimal + * and the scale stores the location of the decimal point. For example 1.23 + * would have precision 3 (3 total digits) and scale 2 (the decimal point is + * 2 digits over). + */ + DECIMAL = 5; + + /** + * A Date + * + * Stored as days since Unix epoch, encoded as the INT32 physical type. + * + */ + DATE = 6; + + /** + * A time + * + * The total number of milliseconds since midnight. The value is stored + * as an INT32 physical type. + */ + TIME_MILLIS = 7; + + /** + * A time. + * + * The total number of microseconds since midnight. The value is stored as + * an INT64 physical type. + */ + TIME_MICROS = 8; + + /** + * A date/time combination + * + * Date and time recorded as milliseconds since the Unix epoch. Recorded as + * a physical type of INT64. + */ + TIMESTAMP_MILLIS = 9; + + /** + * A date/time combination + * + * Date and time recorded as microseconds since the Unix epoch. The value is + * stored as an INT64 physical type. + */ + TIMESTAMP_MICROS = 10; + + + /** + * An unsigned integer value. + * + * The number describes the maximum number of meaningful data bits in + * the stored value. 8, 16 and 32 bit values are stored using the + * INT32 physical type. 64 bit values are stored using the INT64 + * physical type. + * + */ + UINT_8 = 11; + UINT_16 = 12; + UINT_32 = 13; + UINT_64 = 14; + + /** + * A signed integer value. + * + * The number describes the maximum number of meaningful data bits in + * the stored value. 8, 16 and 32 bit values are stored using the + * INT32 physical type. 64 bit values are stored using the INT64 + * physical type. + * + */ + INT_8 = 15; + INT_16 = 16; + INT_32 = 17; + INT_64 = 18; + + /** + * An embedded JSON document + * + * A JSON document embedded within a single UTF8 column. + */ + JSON = 19; + + /** + * An embedded BSON document + * + * A BSON document embedded within a single BYTE_ARRAY column. + */ + BSON = 20; + + /** + * An interval of time + * + * This type annotates data stored as a FIXED_LEN_BYTE_ARRAY of length 12 + * This data is composed of three separate little endian unsigned + * integers. Each stores a component of a duration of time. The first + * integer identifies the number of months associated with the duration, + * the second identifies the number of days associated with the duration + * and the third identifies the number of milliseconds associated with + * the provided duration. This duration of time is independent of any + * particular timezone or date. + */ + INTERVAL = 21; +} + +/** + * Representation of Schemas + */ +enum FieldRepetitionType { + /** This field is required (can not be null) and each row has exactly 1 value. */ + REQUIRED = 0; + + /** The field is optional (can be null) and each row has 0 or 1 values. */ + OPTIONAL = 1; + + /** The field is repeated and can contain 0 or more values */ + REPEATED = 2; +} + +/** + * A structure for capturing metadata for estimating the unencoded, + * uncompressed size of data written. This is useful for readers to estimate + * how much memory is needed to reconstruct data in their memory model and for + * fine grained filter pushdown on nested structures (the histograms contained + * in this structure can help determine the number of nulls at a particular + * nesting level and maximum length of lists). + */ +struct SizeStatistics { + /** + * The number of physical bytes stored for BYTE_ARRAY data values assuming + * no encoding. This is exclusive of the bytes needed to store the length of + * each byte array. In other words, this field is equivalent to the `(size + * of PLAIN-ENCODING the byte array values) - (4 bytes * number of values + * written)`. To determine unencoded sizes of other types readers can use + * schema information multiplied by the number of non-null and null values. + * The number of null/non-null values can be inferred from the histograms + * below. + * + * For example, if a column chunk is dictionary-encoded with dictionary + * ["a", "bc", "cde"], and a data page contains the indices [0, 0, 1, 2], + * then this value for that data page should be 7 (1 + 1 + 2 + 3). + * + * This field should only be set for types that use BYTE_ARRAY as their + * physical type. + */ + 1: optional i64 unencoded_byte_array_data_bytes; + /** + * When present, there is expected to be one element corresponding to each + * repetition (i.e. size=max repetition_level+1) where each element + * represents the number of times the repetition level was observed in the + * data. + * + * This field may be omitted if max_repetition_level is 0 without loss + * of information. + **/ + 2: optional list repetition_level_histogram; + /** + * Same as repetition_level_histogram except for definition levels. + * + * This field may be omitted if max_definition_level is 0 or 1 without + * loss of information. + **/ + 3: optional list definition_level_histogram; +} + +/** + * Bounding box for GEOMETRY or GEOGRAPHY type in the representation of min/max + * value pair of coordinates from each axis. + */ +struct BoundingBox { + 1: required double xmin; + 2: required double xmax; + 3: required double ymin; + 4: required double ymax; + 5: optional double zmin; + 6: optional double zmax; + 7: optional double mmin; + 8: optional double mmax; +} + +/** Statistics specific to Geometry and Geography logical types */ +struct GeospatialStatistics { + /** A bounding box of geospatial instances */ + 1: optional BoundingBox bbox; + /** Geospatial type codes of all instances, or an empty list if not known */ + 2: optional list geospatial_types; +} + +/** + * Statistics per row group and per page + * All fields are optional. + */ +struct Statistics { + /** + * DEPRECATED: min and max value of the column. Use min_value and max_value. + * + * Values are encoded using PLAIN encoding, except that variable-length byte + * arrays do not include a length prefix. + * + * These fields encode min and max values determined by signed comparison + * only. New files should use the correct order for a column's logical type + * and store the values in the min_value and max_value fields. + * + * To support older readers, these may be set when the column order is + * signed. + */ + 1: optional binary max; + 2: optional binary min; + /** + * Count of null values in the column. + * + * Writers SHOULD always write this field even if it is zero (i.e. no null value) + * or the column is not nullable. + * Readers MUST distinguish between null_count not being present and null_count == 0. + * If null_count is not present, readers MUST NOT assume null_count == 0. + */ + 3: optional i64 null_count; + /** count of distinct values occurring */ + 4: optional i64 distinct_count; + /** + * Lower and upper bound values for the column, determined by its ColumnOrder. + * + * These may be the actual minimum and maximum values found on a page or column + * chunk, but can also be (more compact) values that do not exist on a page or + * column chunk. For example, instead of storing "Blart Versenwald III", a writer + * may set min_value="B", max_value="C". Such more compact values must still be + * valid values within the column's logical type. + * + * Values are encoded using PLAIN encoding, except that variable-length byte + * arrays do not include a length prefix. + */ + 5: optional binary max_value; + 6: optional binary min_value; + /** If true, max_value is the actual maximum value for a column */ + 7: optional bool is_max_value_exact; + /** If true, min_value is the actual minimum value for a column */ + 8: optional bool is_min_value_exact; + /** + * Count of NaN values in the column; only present if physical type is FLOAT + * or DOUBLE, or logical type is FLOAT16. + * If this field is not present, readers MUST assume NaNs may be present + * (i.e. MUST assume nan_count > 0 and MAY NOT assume nan_count == 0). + */ + 9: optional i64 nan_count; +} + +/** Empty structs to use as logical type annotations */ +struct StringType {} // allowed for BYTE_ARRAY, must be encoded with UTF-8 +struct UUIDType {} // allowed for FIXED[16], must be encoded as raw UUID bytes +struct MapType {} // see LogicalTypes.md +struct ListType {} // see LogicalTypes.md +struct EnumType {} // allowed for BYTE_ARRAY, must be encoded with UTF-8 +struct DateType {} // allowed for INT32 +struct Float16Type {} // allowed for FIXED[2], must be encoded as raw FLOAT16 bytes (see LogicalTypes.md) + +/** + * Logical type to annotate a column that is always null. + * + * Sometimes when discovering the schema of existing data, values are always + * null and the physical type can't be determined. This annotation signals + * the case where the physical type was guessed from all null values. + */ +struct NullType {} // allowed for any physical type, only null values stored + +/** + * Decimal logical type annotation + * + * Scale must be zero or a positive integer less than or equal to the precision. + * Precision must be a non-zero positive integer. + * + * To maintain forward-compatibility in v1, implementations using this logical + * type must also set scale and precision on the annotated SchemaElement. + * + * Allowed for physical types: INT32, INT64, FIXED_LEN_BYTE_ARRAY, and BYTE_ARRAY. + */ +struct DecimalType { + 1: required i32 scale + 2: required i32 precision +} + +/** Time units for logical types */ +struct MilliSeconds {} +struct MicroSeconds {} +struct NanoSeconds {} +union TimeUnit { + 1: MilliSeconds MILLIS + 2: MicroSeconds MICROS + 3: NanoSeconds NANOS +} + +/** + * Timestamp logical type annotation + * + * Allowed for physical types: INT64 + */ +struct TimestampType { + 1: required bool isAdjustedToUTC + 2: required TimeUnit unit +} + +/** + * Time logical type annotation + * + * Allowed for physical types: INT32 (millis), INT64 (micros, nanos) + */ +struct TimeType { + 1: required bool isAdjustedToUTC + 2: required TimeUnit unit +} + +/** + * Integer logical type annotation + * + * bitWidth must be 8, 16, 32, or 64. + * + * Allowed for physical types: INT32, INT64 + */ +struct IntType { + 1: required i8 bitWidth + 2: required bool isSigned +} + +/** + * Embedded JSON logical type annotation + * + * Allowed for physical types: BYTE_ARRAY + */ +struct JsonType { +} + +/** + * Embedded BSON logical type annotation + * + * Allowed for physical types: BYTE_ARRAY + */ +struct BsonType { +} + +/** + * Embedded Variant logical type annotation + */ +struct VariantType { + // The version of the variant specification that the variant was + // written with. + 1: optional i8 specification_version +} + +/** Edge interpolation algorithm for Geography logical type */ +enum EdgeInterpolationAlgorithm { + SPHERICAL = 0; + VINCENTY = 1; + THOMAS = 2; + ANDOYER = 3; + KARNEY = 4; +} + +/** + * Embedded Geometry logical type annotation + * + * Geospatial features in the Well-Known Binary (WKB) format and `edges` interpolation + * is always linear/planar. + * + * A custom CRS can be set by the crs field. If unset, it defaults to "OGC:CRS84", + * which means that the geometries must be stored in longitude, latitude based on + * the WGS84 datum. + * + * Allowed for physical type: BYTE_ARRAY. + * + * See Geospatial.md for details. + */ +struct GeometryType { + 1: optional string crs; +} + +/** + * Embedded Geography logical type annotation + * + * Geospatial features in the WKB format with an explicit (non-linear/non-planar) + * `edges` interpolation algorithm. + * + * A custom geographic CRS can be set by the crs field, where longitudes are + * bound by [-180, 180] and latitudes are bound by [-90, 90]. If unset, the CRS + * defaults to "OGC:CRS84". + * + * An optional algorithm can be set to correctly interpret `edges` interpolation + * of the geometries. If unset, the algorithm defaults to SPHERICAL. + * + * Allowed for physical type: BYTE_ARRAY. + * + * See Geospatial.md for details. + */ +struct GeographyType { + 1: optional string crs; + 2: optional EdgeInterpolationAlgorithm algorithm; +} + +/** + * File logical type annotation + * + * Annotates a group that represents a reference to a file, or to a range of + * bytes that may be stored inline, elsewhere in this file, or in an external + * file. + * + * See LogicalTypes.md for details. + */ +struct FileType { +} + +/** + * LogicalType annotations to replace ConvertedType. + * + * To maintain compatibility, implementations using LogicalType for a + * SchemaElement must also set the corresponding ConvertedType (if any) + * from the following table. + */ +union LogicalType { + 1: StringType STRING // use ConvertedType UTF8 + 2: MapType MAP // use ConvertedType MAP + 3: ListType LIST // use ConvertedType LIST + 4: EnumType ENUM // use ConvertedType ENUM + 5: DecimalType DECIMAL // use ConvertedType DECIMAL + SchemaElement.{scale, precision} + 6: DateType DATE // use ConvertedType DATE + + // use ConvertedType TIME_MICROS for TIME(isAdjustedToUTC = *, unit = MICROS) + // use ConvertedType TIME_MILLIS for TIME(isAdjustedToUTC = *, unit = MILLIS) + 7: TimeType TIME + + // use ConvertedType TIMESTAMP_MICROS for TIMESTAMP(isAdjustedToUTC = *, unit = MICROS) + // use ConvertedType TIMESTAMP_MILLIS for TIMESTAMP(isAdjustedToUTC = *, unit = MILLIS) + 8: TimestampType TIMESTAMP + + // 9: reserved for INTERVAL + 10: IntType INTEGER // use ConvertedType INT_* or UINT_* + 11: NullType UNKNOWN // no compatible ConvertedType + 12: JsonType JSON // use ConvertedType JSON + 13: BsonType BSON // use ConvertedType BSON + 14: UUIDType UUID // no compatible ConvertedType + 15: Float16Type FLOAT16 // no compatible ConvertedType + 16: VariantType VARIANT // no compatible ConvertedType + 17: GeometryType GEOMETRY // no compatible ConvertedType + 18: GeographyType GEOGRAPHY // no compatible ConvertedType + 19: FileType FILE // no compatible ConvertedType +} + +/** + * Represents an element inside a schema definition. + * - if it is a group (inner node) then type is undefined and num_children is defined + * - if it is a primitive type (leaf) then type is defined and num_children is undefined + * the nodes are listed in depth first traversal order. + */ +struct SchemaElement { + /** Data type for this field. Not set if the current element is a non-leaf node */ + 1: optional Type type; + + /** If type is FIXED_LEN_BYTE_ARRAY, this is the byte length of the values. + * Otherwise, if specified, this is the maximum bit length to store any of the values. + * (e.g. a low cardinality INT col could have this set to 3). Note that this is + * in the schema, and therefore fixed for the entire file. + */ + 2: optional i32 type_length; + + /** repetition of the field. The root of the schema does not have a repetition_type. + * All other nodes must have one */ + 3: optional FieldRepetitionType repetition_type; + + /** Name of the field in the schema */ + 4: required string name; + + /** Nested fields. Since thrift does not support nested fields, + * the nesting is flattened to a single list by a depth-first traversal. + * The children count is used to construct the nested relationship. + * This field is not set when the element is a primitive type + */ + 5: optional i32 num_children; + + /** + * DEPRECATED: When the schema is the result of a conversion from another model. + * Used to record the original type to help with cross conversion. + * + * This is superseded by logicalType. + */ + 6: optional ConvertedType converted_type; + + /** + * DEPRECATED: Used when this column contains decimal data. + * See the DECIMAL converted type for more details. + * + * This is superseded by using the DecimalType annotation in logicalType. + */ + 7: optional i32 scale + 8: optional i32 precision + + /** When the original schema supports field ids, this will save the + * original field id in the parquet schema + */ + 9: optional i32 field_id; + + /** + * The logical type of this SchemaElement + * + * LogicalType replaces ConvertedType, but ConvertedType is still required + * for some logical types to ensure forward-compatibility in format v1. + */ + 10: optional LogicalType logicalType +} + +/** + * Encodings supported by Parquet. Not all encodings are valid for all types. These + * enums are also used to specify the encoding of definition and repetition levels. + * See the accompanying doc for the details of the more complicated encodings. + */ +enum Encoding { + /** Default encoding. + * BOOLEAN - 1 bit per value. 0 is false; 1 is true. + * INT32 - 4 bytes per value. Stored as little-endian. + * INT64 - 8 bytes per value. Stored as little-endian. + * FLOAT - 4 bytes per value. IEEE. Stored as little-endian. + * DOUBLE - 8 bytes per value. IEEE. Stored as little-endian. + * BYTE_ARRAY - 4 byte length stored as little endian, followed by bytes. + * FIXED_LEN_BYTE_ARRAY - Just the bytes. + */ + PLAIN = 0; + + /** Group VarInt encoding for INT32/INT64. + * This encoding is deprecated. It was never used. + */ + // GROUP_VAR_INT = 1; + + /** + * DEPRECATED: Dictionary encoding. The values in the dictionary are encoded in the + * plain type. + * For a data page use RLE_DICTIONARY instead. + * For a Dictionary page use PLAIN instead. + */ + PLAIN_DICTIONARY = 2; + + /** Group packed run length encoding. Usable for definition/repetition levels + * encoding and Booleans (on one bit: 0 is false; 1 is true.) + */ + RLE = 3; + + /** DEPRECATED: Bit packed encoding. This can only be used if the data has a known max + * width. Usable for definition/repetition levels encoding. + * Superseded by RLE (which is a hybrid of RLE and bit packing); see Encodings.md. + */ + BIT_PACKED = 4; + + /** Delta encoding for integers. This can be used for int columns and works best + * on sorted data + */ + DELTA_BINARY_PACKED = 5; + + /** Encoding for byte arrays to separate the length values and the data. The lengths + * are encoded using DELTA_BINARY_PACKED + */ + DELTA_LENGTH_BYTE_ARRAY = 6; + + /** Incremental-encoded byte array. Prefix lengths are encoded using DELTA_BINARY_PACKED. + * Suffixes are stored as delta length byte arrays. + */ + DELTA_BYTE_ARRAY = 7; + + /** Dictionary encoding: the ids are encoded using the RLE encoding + */ + RLE_DICTIONARY = 8; + + /** Encoding for fixed-width data (FLOAT, DOUBLE, INT32, INT64, FIXED_LEN_BYTE_ARRAY). + K byte-streams are created where K is the size in bytes of the data type. + The individual bytes of a value are scattered to the corresponding stream and + the streams are concatenated. + This itself does not reduce the size of the data but can lead to better compression + afterwards. + + Added in 2.8 for FLOAT and DOUBLE. + Support for INT32, INT64 and FIXED_LEN_BYTE_ARRAY added in 2.11. + */ + BYTE_STREAM_SPLIT = 9; + + /** Adaptive Lossless floating-Point (ALP) encoding for FLOAT and DOUBLE. + Losslessly converts decimal-like floating-point values to integers via + decimal scaling, then applies Frame of Reference (FOR) encoding and + bit-packing; values that cannot be converted losslessly are stored as + exceptions. See Encodings.md for the detailed specification. + */ + ALP = 10; +} + +/** + * Supported compression algorithms. + * + * Codecs added in format version X.Y can be read by readers based on X.Y and later. + * Codec support may vary between readers based on the format version and + * libraries available at runtime. + * + * See Compression.md for a detailed specification of these algorithms. + */ +enum CompressionCodec { + UNCOMPRESSED = 0; + SNAPPY = 1; + GZIP = 2; + LZO = 3; + BROTLI = 4; // Added in 2.4 + LZ4 = 5; // DEPRECATED (Added in 2.4) + ZSTD = 6; // Added in 2.4 + LZ4_RAW = 7; // Added in 2.9 +} + +enum PageType { + DATA_PAGE = 0; + INDEX_PAGE = 1; + DICTIONARY_PAGE = 2; + DATA_PAGE_V2 = 3; +} + +/** + * Enum to annotate whether lists of min/max elements inside ColumnIndex + * are ordered and if so, in which direction. + */ +enum BoundaryOrder { + UNORDERED = 0; + ASCENDING = 1; + DESCENDING = 2; +} + +/** Data page header */ +struct DataPageHeader { + /** + * Number of values, including NULLs, in this data page. + * + * If an OffsetIndex is present, a page must begin at a row + * boundary (repetition_level = 0). Otherwise, pages may begin + * within a row (repetition_level > 0). + **/ + 1: required i32 num_values + + /** Encoding used for this data page **/ + 2: required Encoding encoding + + /** Encoding used for definition levels **/ + 3: required Encoding definition_level_encoding; + + /** Encoding used for repetition levels **/ + 4: required Encoding repetition_level_encoding; + + /** Optional statistics for the data in this page **/ + 5: optional Statistics statistics; +} + +struct IndexPageHeader { + // TODO +} + +/** + * The dictionary page must be placed at the first position of the column chunk + * if it is partly or completely dictionary encoded. At most one dictionary page + * can be placed in a column chunk. + **/ +struct DictionaryPageHeader { + /** Number of values in the dictionary **/ + 1: required i32 num_values; + + /** Encoding using this dictionary page **/ + 2: required Encoding encoding + + /** If true, the entries in the dictionary are sorted in ascending order **/ + 3: optional bool is_sorted; +} + +/** + * Alternate page format allowing reading levels without decompressing the data + * Repetition and definition levels are uncompressed + * The remaining section containing the data is compressed if is_compressed is true + * + * Implementation note - this header is not necessarily a strict improvement over + * `DataPageHeader` (in particular the original header might provide better compression + * in some scenarios). Page indexes require pages to start and end at row boundaries, + * regardless of which page header is used. + **/ +struct DataPageHeaderV2 { + /** Number of values, including NULLs, in this data page. **/ + 1: required i32 num_values + /** Number of NULL values, in this data page. + Number of non-null = num_values - num_nulls which is also the number of values in the data section **/ + 2: required i32 num_nulls + /** + * Number of rows in this data page. Every page must begin at a + * row boundary (repetition_level = 0): rows must **not** be + * split across page boundaries when using V2 data pages. + **/ + 3: required i32 num_rows + /** Encoding used for data in this page **/ + 4: required Encoding encoding + + // repetition levels and definition levels are always using RLE (without size in it) + + /** Length of the definition levels */ + 5: required i32 definition_levels_byte_length; + /** Length of the repetition levels */ + 6: required i32 repetition_levels_byte_length; + + /** Whether the values are compressed. + Which means the section of the page between + definition_levels_byte_length + repetition_levels_byte_length and compressed_page_size (included) + is compressed with the compression_codec. + If missing it is considered compressed */ + 7: optional bool is_compressed = true; + + /** Optional statistics for the data in this page **/ + 8: optional Statistics statistics; +} + +/** Block-based algorithm type annotation. **/ +struct SplitBlockAlgorithm {} +/** The algorithm used in Bloom filter. **/ +union BloomFilterAlgorithm { + /** Block-based Bloom filter. **/ + 1: SplitBlockAlgorithm BLOCK; +} + +/** Hash strategy type annotation. xxHash is an extremely fast non-cryptographic hash + * algorithm. It uses 64 bits version of xxHash. + **/ +struct XxHash {} + +/** + * The hash function used in Bloom filter. This function takes the hash of a column value + * using plain encoding. + **/ +union BloomFilterHash { + /** xxHash Strategy. **/ + 1: XxHash XXHASH; +} + +/** + * The compression used in the Bloom filter. + **/ +struct Uncompressed {} +union BloomFilterCompression { + 1: Uncompressed UNCOMPRESSED; +} + +/** + * Bloom filter header is stored at beginning of Bloom filter data of each column + * and followed by its bitset. + **/ +struct BloomFilterHeader { + /** The size of bitset in bytes **/ + 1: required i32 numBytes; + /** The algorithm for setting bits. **/ + 2: required BloomFilterAlgorithm algorithm; + /** The hash function used for Bloom filter. **/ + 3: required BloomFilterHash hash; + /** The compression used in the Bloom filter **/ + 4: required BloomFilterCompression compression; +} + +struct PageHeader { + /** the type of the page: indicates which of the *_header fields is set **/ + 1: required PageType type + + /** Uncompressed page size in bytes (not including this header) **/ + 2: required i32 uncompressed_page_size + + /** Compressed (and potentially encrypted) page size in bytes, not including this header **/ + 3: required i32 compressed_page_size + + /** The 32-bit CRC checksum for the page, to be calculated as follows: + * + * - The standard CRC32 algorithm is used (with polynomial 0x04C11DB7, + * the same as in e.g. GZIP). + * - All page types can have a CRC (v1 and v2 data pages, dictionary pages, + * etc.). + * - The CRC is computed on the serialization binary representation of the page + * (as written to disk), excluding the page header. For example, for v1 + * data pages, the CRC is computed on the concatenation of repetition levels, + * definition levels and column values (optionally compressed, optionally + * encrypted). + * - The CRC computation therefore takes place after any compression + * and encryption steps, if any. + * + * If enabled, this allows for disabling checksumming in HDFS if only a few + * pages need to be read. + */ + 4: optional i32 crc + + // Headers for page specific data. One only will be set. + 5: optional DataPageHeader data_page_header; + 6: optional IndexPageHeader index_page_header; + 7: optional DictionaryPageHeader dictionary_page_header; + 8: optional DataPageHeaderV2 data_page_header_v2; +} + +/** + * Wrapper struct to store key values + */ + struct KeyValue { + 1: required string key + 2: optional string value +} + +/** + * Sort order within a RowGroup of a leaf column + */ +struct SortingColumn { + /** The ordinal position of the column (in this row group) **/ + 1: required i32 column_idx + + /** If true, indicates this column is sorted in descending order. **/ + 2: required bool descending + + /** If true, nulls will come before non-null values, otherwise, + * nulls go at the end. */ + 3: required bool nulls_first +} + +/** + * statistics of a given page type and encoding + */ +struct PageEncodingStats { + + /** the page type (data/dic/...) **/ + 1: required PageType page_type; + + /** encoding of the page **/ + 2: required Encoding encoding; + + /** number of pages of this type with this encoding **/ + 3: required i32 count; + +} + +/** + * Description for column metadata + */ +struct ColumnMetaData { + /** Type of this column **/ + 1: required Type type + + /** Set of all encodings used for this column. The purpose is to validate + * whether we can decode those pages. **/ + 2: required list encodings + + /** Path in schema **/ + 3: required list path_in_schema + + /** Compression codec **/ + 4: required CompressionCodec codec + + /** Number of values in this column **/ + 5: required i64 num_values + + /** total byte size of all uncompressed pages in this column chunk (including the headers) **/ + 6: required i64 total_uncompressed_size + + /** total byte size of all compressed, and potentially encrypted, pages + * in this column chunk (including the headers) **/ + 7: required i64 total_compressed_size + + /** Optional key/value metadata **/ + 8: optional list key_value_metadata + + /** Byte offset from beginning of file to first data page **/ + 9: required i64 data_page_offset + + /** Byte offset from beginning of file to root index page **/ + 10: optional i64 index_page_offset + + /** Byte offset from the beginning of file to first (only) dictionary page **/ + 11: optional i64 dictionary_page_offset + + /** optional statistics for this column chunk */ + 12: optional Statistics statistics; + + /** Set of all encodings used for pages in this column chunk. + * This information can be used to determine if all data pages are + * dictionary encoded for example **/ + 13: optional list encoding_stats; + + /** Byte offset from beginning of file to Bloom filter data. **/ + 14: optional i64 bloom_filter_offset; + + /** Size of Bloom filter data including the serialized header, in bytes. + * Added in 2.10 so readers may not read this field from old files and + * it can be obtained after the BloomFilterHeader has been deserialized. + * Writers should write this field so readers can read the bloom filter + * in a single I/O. + */ + 15: optional i32 bloom_filter_length; + + /** + * Optional statistics to help estimate total memory when converted to in-memory + * representations. The histograms contained in these statistics can + * also be useful in some cases for more fine-grained nullability/list length + * filter pushdown. + */ + 16: optional SizeStatistics size_statistics; + + /** Optional statistics specific for Geometry and Geography logical types */ + 17: optional GeospatialStatistics geospatial_statistics; +} + +struct EncryptionWithFooterKey { +} + +struct EncryptionWithColumnKey { + /** Column path in schema **/ + 1: required list path_in_schema + + /** Retrieval metadata of column encryption key **/ + 2: optional binary key_metadata +} + +union ColumnCryptoMetaData { + 1: EncryptionWithFooterKey ENCRYPTION_WITH_FOOTER_KEY + 2: EncryptionWithColumnKey ENCRYPTION_WITH_COLUMN_KEY +} + +struct ColumnChunk { + /** File where column data is stored. If not set, assumed to be same file as + * metadata. This path is relative to the current file. + * + * As of December 2025, the only known use-case for this field is writing summary + * parquet files (i.e. "_metadata" files). These files consolidate footers from + * multiple parquet files to allow for efficient reading of footers to avoid file + * listing costs and prune out files that do not need to be read based on statistics. + * + * These files do not appear to have ever been formally specified in the specification. + * and are potentially problematic from a correctness perspective [1]. + * + * [1] https://lists.apache.org/thread/ootf2kmyg3p01b1bvplpvp4ftd1bt72d + * + * There is no other known usage of this field. Specifically, there are no known + * reference implementations that will read externally stored column data if this field is populated + * within a standard parquet file. Making use of the field for this purpose is + * not considered part of the Parquet specification. + **/ + 1: optional string file_path + + /** DEPRECATED: Byte offset in file_path to the ColumnMetaData + * + * Past use of this field has been inconsistent, with some implementations + * using it to point to the ColumnMetaData and some using it to point to + * the first page in the column chunk. In many cases, the ColumnMetaData at this + * location is wrong. This field is now deprecated and should not be used. + * Writers should set this field to 0 if no ColumnMetaData has been written outside + * the footer. + */ + 2: required i64 file_offset = 0 + + /** Column metadata for this chunk. Some writers may also replicate this at the + * location pointed to by file_path/file_offset. + * Note: while marked as optional, this field is in fact required by most major + * Parquet implementations. As such, writers MUST populate this field. + **/ + 3: optional ColumnMetaData meta_data + + /** File offset of ColumnChunk's OffsetIndex **/ + 4: optional i64 offset_index_offset + + /** Size of ColumnChunk's OffsetIndex, in bytes **/ + 5: optional i32 offset_index_length + + /** File offset of ColumnChunk's ColumnIndex **/ + 6: optional i64 column_index_offset + + /** Size of ColumnChunk's ColumnIndex, in bytes **/ + 7: optional i32 column_index_length + + /** Crypto metadata of encrypted columns **/ + 8: optional ColumnCryptoMetaData crypto_metadata + + /** Encrypted column metadata for this chunk **/ + 9: optional binary encrypted_column_metadata +} + +struct RowGroup { + /** Metadata for each column chunk in this row group. + * This list must have the same order as the SchemaElement list in FileMetaData. + **/ + 1: required list columns + + /** Total byte size of all the uncompressed column data in this row group **/ + 2: required i64 total_byte_size + + /** Number of rows in this row group **/ + 3: required i64 num_rows + + /** If set, specifies a sort ordering of the rows in this RowGroup. + * The sorting columns can be a subset of all the columns. + */ + 4: optional list sorting_columns + + /** Byte offset from beginning of file to first page (data or dictionary) + * in this row group **/ + 5: optional i64 file_offset + + /** Total byte size of all compressed (and potentially encrypted) column data + * in this row group **/ + 6: optional i64 total_compressed_size + + /** Row group ordinal in the file **/ + 7: optional i16 ordinal +} + +/** Empty struct to signal the order defined by the physical or logical type */ +struct TypeDefinedOrder {} + +/** Empty struct to signal IEEE 754 total order for floating point types */ +struct IEEE754TotalOrder {} + +/** Empty struct to signal chronological ordering of physical type INT96 */ +struct Int96TimestampOrder {} + +/** + * Union to specify the order used for the min_value and max_value fields for a + * column. This union takes the role of an enhanced enum that allows rich + * elements (which will be needed for a collation-based ordering in the future). + * + * Possible values are: + * * TypeDefinedOrder - the column uses the order defined by its logical or + * physical type (if there is no logical type). + * * IEEE754TotalOrder - the floating point column uses IEEE 754 total order. + * + * * Int96TimestampOrder - the INT96 column uses chronological timestamp order. + * + * If the reader does not support the value of this union, min and max stats + * for this column should be ignored. + */ +union ColumnOrder { + + /** + * The sort orders for logical types are: + * UTF8 - unsigned byte-wise comparison + * INT8 - signed comparison + * INT16 - signed comparison + * INT32 - signed comparison + * INT64 - signed comparison + * UINT8 - unsigned comparison + * UINT16 - unsigned comparison + * UINT32 - unsigned comparison + * UINT64 - unsigned comparison + * DECIMAL - signed comparison of the represented value + * DATE - signed comparison + * FLOAT16 - signed comparison of the represented value (*) + * TIME_MILLIS - signed comparison + * TIME_MICROS - signed comparison + * TIMESTAMP_MILLIS - signed comparison + * TIMESTAMP_MICROS - signed comparison + * INTERVAL - undefined + * JSON - unsigned byte-wise comparison + * BSON - unsigned byte-wise comparison + * ENUM - unsigned byte-wise comparison + * LIST - undefined + * MAP - undefined + * VARIANT - undefined + * GEOMETRY - undefined + * GEOGRAPHY - undefined + * FILE - undefined + * + * In the absence of logical types, the sort order is determined by the physical type: + * BOOLEAN - false, true + * INT32 - signed comparison + * INT64 - signed comparison + * INT96 (only used for legacy timestamps) - depends on sort order (+) + * FLOAT - signed comparison of the represented value (*) + * DOUBLE - signed comparison of the represented value (*) + * BYTE_ARRAY - unsigned byte-wise comparison + * FIXED_LEN_BYTE_ARRAY - unsigned byte-wise comparison + * + * (+) While the INT96 type has been deprecated, at the time of writing it is + * still used in many legacy systems. It is optional for writers to emit + * statistics for INT96 columns. Writers that emit stats for such columns + * should use the INT96_TIMESTAMP_ORDER for this type and order the values + * according to the legacy rules: + * - compare the last 4 bytes (days) as a little-endian 32-bit signed integer + * - if equal last 4 bytes, compare the first 8 bytes as a little-endian + * 64-bit signed integer (nanos) + * If TYPE_ORDER is used for an INT96 column, readers should ignore all statistics + * (`min`/`max` fields in `Statistics` and `min_values`/`max_values` fields in + * `ColumnIndex`) for that column. + * + * (*) Because TYPE_ORDER is ambiguous for floating point types due to + * underspecified handling of NaN and -0/+0, it is recommended that writers + * use IEEE_754_TOTAL_ORDER for these types. + * + * If TYPE_ORDER is used for floating point types, then the following + * compatibility rules should be applied when reading statistics: + * - If the min is a NaN, it should be ignored. + * - If the max is a NaN, it should be ignored. + * - If the nan_count field is set, a reader can compute + * nan_count + null_count == num_values to deduce whether all non-null + * values are NaN. + * - If the min is +0, the row group may contain -0 values as well. + * - If the max is -0, the row group may contain +0 values as well. + * - When looking for NaN values, min and max should be ignored. + * If the nan_count field is set, it can be used to check whether + * NaNs are present. + * + * When writing page or column chunk statistics for columns with + * TYPE_ORDER order, the following rules must be followed: + * - The nan_count field must be set for floating point types, even if + * it is zero. + * - If the nan_count field is set, min and max statistics fields, when + * present, must not contain NaN values and must be computed from + * non-NaN values only. This signals to readers that the min and max + * statistics are reliable for non-NaN values. + * - If all non-null values are NaN, min and max statistics must not be + * written. + * - If the computed max value is zero (whether negative or positive), + * `+0.0` should be written into the max statistics field. + * - If the computed min value is zero (whether negative or positive), + * `-0.0` should be written into the min statistics field. + * + * When writing column indexes for columns with TYPE_ORDER order, the + * following rules must be followed: + * - NaNs must not be written to min_values or max_values. + * - If all non-null values of a page are NaN, a column index must not + * be written for this column chunk because min_values and max_values + * are required. + * - If the computed max value is zero (whether negative or positive), + * `+0.0` should be written into the corresponding max_values entry. + * - If the computed min value is zero (whether negative or positive), + * `-0.0` should be written into the corresponding min_values entry. + */ + 1: TypeDefinedOrder TYPE_ORDER; + + /* + * The floating point type is ordered according to the totalOrder predicate, + * as defined in section 5.10 of IEEE-754 (2008 revision). Only columns of + * physical type FLOAT or DOUBLE, or logical type FLOAT16 may use this ordering. + * + * Intuitively, this orders floats mathematically, but defines -0 to be less + * than +0, -NaN to be less than anything else, and +NaN to be greater than + * anything else. It also defines an order between different bit representations + * of the same value. + * + * When writing statistics for columns with IEEE_754_TOTAL_ORDER order, then + * following rules must be followed: + * - Writing the nan_count field is mandatory when using this ordering. + * - Min and max statistics must contain the smallest and largest non-NaN + * values respectively, or if all non-null values are NaN, the smallest and + * largest NaN values as defined by IEEE 754 total order. + * + * When reading statistics for columns with this order, the following rules + * should be followed: + * - Readers should consult the nan_count field to determine whether NaNs + * are present. + * - A reader can compute nan_count + null_count == num_values to deduce + * whether all non-null values are NaN. In the page index, which does not + * have a num_values field, the presence of a NaN value in min_values + * or max_values indicates that all non-null values are NaN. + */ + 2: IEEE754TotalOrder IEEE_754_TOTAL_ORDER; + + /* + * The INT96 timestamp type is ordered chronologically. Only columns of + * physical type INT96 may use this ordering. + */ + 3: Int96TimestampOrder INT96_TIMESTAMP_ORDER; +} + +struct PageLocation { + /** Offset of the page in the file **/ + 1: required i64 offset + + /** + * Size of the page, including header. Equal to the sum of the page's + * PageHeader.compressed_page_size and the size of the serialized PageHeader. + */ + 2: required i32 compressed_page_size + + /** + * Index within the RowGroup of the first row of the page. When an + * OffsetIndex is present, pages must begin on row boundaries + * (repetition_level = 0). + */ + 3: required i64 first_row_index +} + +/** + * Optional offsets for each data page in a ColumnChunk. + * + * Forms part of the page index, along with ColumnIndex. + * + * OffsetIndex may be present even if ColumnIndex is not. + */ +struct OffsetIndex { + /** + * PageLocations, ordered by increasing PageLocation.offset. It is required + * that page_locations[i].first_row_index < page_locations[i+1].first_row_index. + */ + 1: required list page_locations + /** + * Unencoded/uncompressed size for BYTE_ARRAY types. + * + * See documentation for unencoded_byte_array_data_bytes in SizeStatistics for + * more details on this field. + */ + 2: optional list unencoded_byte_array_data_bytes +} + +/** + * Optional statistics for each data page in a ColumnChunk. + * + * Forms part the page index, along with OffsetIndex. + * + * If this structure is present, OffsetIndex must also be present. + * + * For each field in this structure, [i] refers to the page at + * OffsetIndex.page_locations[i] + */ +struct ColumnIndex { + /** + * A list of Boolean values to determine the validity of the corresponding + * min and max values. If true, a page contains only null values, and writers + * have to set the corresponding entries in min_values and max_values to + * byte[0], so that all lists have the same length. If false, the + * corresponding entries in min_values and max_values must be valid. + */ + 1: required list null_pages + + /** + * Two lists containing lower and upper bounds for the values of each page + * determined by the ColumnOrder of the column. These may be the actual + * minimum and maximum values found on a page, but can also be (more compact) + * values that do not exist on a page. For example, instead of storing "Blart + * Versenwald III", a writer may set min_values[i]="B", max_values[i]="C". + * Such more compact values must still be valid values within the column's + * logical type. Readers must make sure that list entries are populated before + * using them by inspecting null_pages. + * + * For columns of physical type FLOAT or DOUBLE, or logical type FLOAT16, + * NaN values are not to be included in these bounds. If all non-null values + * of a page are NaN, then a writer must do the following: + * - If the order of this column is TYPE_ORDER, then a column index must + * not be written for this column chunk. While this is unfortunate for + * performance, it is necessary to avoid conflict with legacy files that + * still included NaN in min_values and max_values even if the page had + * non-NaN values. To mitigate this, IEEE754_TOTAL_ORDER is recommended. + * - If the order of this column is IEEE754_TOTAL_ORDER, then min_values[i] + * and max_values[i] of that page must be set to the smallest and largest + * NaN values as defined by IEEE 754 total order. + * + * For columns of physical type INT96, the writer must do the following: + * - If the order of this column is not INT96_TIMESTAMP_ORDER, then a column + * index must not be written for this column chunk. + * - If the order of this column is INT96_TIMESTAMP_ORDER, the min_values[i] + * and max_values[i] of that page must be set to the smallest and largest + * values as defined by the INT96 chronological timestamp ordering. + */ + 2: required list min_values + 3: required list max_values + + /** + * Stores whether both min_values and max_values are ordered and if so, in + * which direction. This allows readers to perform binary searches in both + * lists. Readers cannot assume that max_values[i] <= min_values[i+1], even + * if the lists are ordered. + */ + 4: required BoundaryOrder boundary_order + + /** + * A list containing the number of null values for each page + * + * Writers SHOULD always write this field even if no null values + * are present or the column is not nullable. + * Readers MUST distinguish between null_counts not being present + * and null_count being 0. + * If null_counts are not present, readers MUST NOT assume all + * null counts are 0. + */ + 5: optional list null_counts + + /** + * Contains repetition level histograms for each page + * concatenated together. The repetition_level_histogram field on + * SizeStatistics contains more details. + * + * When present the length should always be (number of pages * + * (max_repetition_level + 1)) elements. + * + * Element 0 is the first element of the histogram for the first page. + * Element (max_repetition_level + 1) is the first element of the histogram + * for the second page. + **/ + 6: optional list repetition_level_histograms; + /** + * Same as repetition_level_histograms except for definitions levels. + **/ + 7: optional list definition_level_histograms; + + /** + * A list containing the number of NaN values for each page. Only present + * for columns of physical type FLOAT or DOUBLE, or logical type FLOAT16. + * If this field is not present, readers MUST assume that there might be + * NaN values in any page. + */ + 8: optional list nan_counts + +} + +struct AesGcmV1 { + /** AAD prefix **/ + 1: optional binary aad_prefix + + /** Unique file identifier part of AAD suffix **/ + 2: optional binary aad_file_unique + + /** In files encrypted with AAD prefix without storing it, + * readers must supply the prefix **/ + 3: optional bool supply_aad_prefix +} + +struct AesGcmCtrV1 { + /** AAD prefix **/ + 1: optional binary aad_prefix + + /** Unique file identifier part of AAD suffix **/ + 2: optional binary aad_file_unique + + /** In files encrypted with AAD prefix without storing it, + * readers must supply the prefix **/ + 3: optional bool supply_aad_prefix +} + +union EncryptionAlgorithm { + 1: AesGcmV1 AES_GCM_V1 + 2: AesGcmCtrV1 AES_GCM_CTR_V1 +} + +/** + * Description for file metadata + */ +struct FileMetaData { + /** Version of this file + * + * As of December 2025, there is no agreed upon consensus of what constitutes + * version 2 of the file. For maximum compatibility with readers, writers should + * always populate "1" for version. For maximum compatibility with writers, + * readers should accept "1" and "2" interchangeably. All other versions are + * reserved for potential future use-cases. + */ + 1: required i32 version + + /** Parquet schema for this file. This schema contains metadata for all the columns. + * The schema is represented as a tree with a single root. The nodes of the tree + * are flattened to a list by doing a depth-first traversal. + * The column metadata contains the path in the schema for that column which can be + * used to map columns to nodes in the schema. + * The first element is the root **/ + 2: required list schema; + + /** Number of rows in this file **/ + 3: required i64 num_rows + + /** Row groups in this file **/ + 4: required list row_groups + + /** Optional key/value metadata **/ + 5: optional list key_value_metadata + + /** String for application that wrote this file. This should be in the format + * version (build ). + * e.g. impala version 1.0 (build 6cf94d29b2b7115df4de2c06e2ab4326d721eb55) + **/ + 6: optional string created_by + + /** + * Sort order used for the min_value and max_value fields in the Statistics + * objects and the min_values and max_values fields in the ColumnIndex + * objects of each column in this file. Sort orders are listed in the order + * matching the columns in the schema. The indexes are not necessarily the same + * though, because only leaf nodes of the schema are represented in the list + * of sort orders. + * + * Without column_orders, the meaning of the min_value and max_value fields + * in the Statistics object and the ColumnIndex object is undefined. To ensure + * well-defined behaviour, if these fields are written to a Parquet file, + * column_orders must be written as well. + * + * The obsolete min and max fields in the Statistics object are always sorted + * by signed comparison regardless of column_orders. + */ + 7: optional list column_orders; + + /** + * Encryption algorithm. This field is set only in encrypted files + * with plaintext footer. Files with encrypted footer store algorithm id + * in FileCryptoMetaData structure. + */ + 8: optional EncryptionAlgorithm encryption_algorithm + + /** + * Retrieval metadata of key used for signing the footer. + * Used only in encrypted files with plaintext footer. + */ + 9: optional binary footer_signing_key_metadata +} + +/** Crypto metadata for files with encrypted footer **/ +struct FileCryptoMetaData { + /** + * Encryption algorithm. This field is only used for files + * with encrypted footer. Files with plaintext footer store algorithm id + * inside footer (FileMetaData structure). + */ + 1: required EncryptionAlgorithm encryption_algorithm + + /** Retrieval metadata of key used for encryption of footer, + * and (possibly) columns **/ + 2: optional binary key_metadata +} diff --git a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs index 81f30280..6008e9a9 100644 --- a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs +++ b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs @@ -176,11 +176,10 @@ openWritableBinaryFile filepath = do pure . WritableBinaryHandle $ h withWritableBinaryFile :: FilePath -> (WritableBinaryHandle -> IO a) -> IO a -withWritableBinaryFile filepath action = +withWritableBinaryFile filepath = bracket (openWritableBinaryFile filepath) (hClose . unHandle) - action class (Monad m) => HasBuffer m where type Buffer m @@ -445,8 +444,8 @@ data BufferHandle = BufferHandle } withFileBuffer :: FilePath -> (BufferHandle -> IO a) -> IO a -withFileBuffer filepath action = - bracket open (hClose . unHandle . bufferHandle) action +withFileBuffer filepath = + bracket open (hClose . unHandle . bufferHandle) where open = do h <- openBinaryFile filepath ReadWriteMode diff --git a/dataframe-parquet/stress/StressMain.hs b/dataframe-parquet/stress/StressMain.hs index c8cd65d4..90b548f0 100644 --- a/dataframe-parquet/stress/StressMain.hs +++ b/dataframe-parquet/stress/StressMain.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE NumericUnderscores #-} module Main (main) where From ddab22cc8b68dc9a57340eb6cda58a678a72482b Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 20:48:18 +0530 Subject: [PATCH 13/21] fourmolu again --- dataframe-parquet/stress/StressMain.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/dataframe-parquet/stress/StressMain.hs b/dataframe-parquet/stress/StressMain.hs index 90b548f0..976ce3d7 100644 --- a/dataframe-parquet/stress/StressMain.hs +++ b/dataframe-parquet/stress/StressMain.hs @@ -1,4 +1,3 @@ - module Main (main) where import Control.Exception (evaluate) From bb46712971228db5b91ed35fc745b7e885d76002 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 20:56:13 +0530 Subject: [PATCH 14/21] parquet.thrift was committed by accident --- .../src/DataFrame/IO/Parquet/parquet.thrift | 1486 ----------------- 1 file changed, 1486 deletions(-) delete mode 100644 dataframe-parquet/src/DataFrame/IO/Parquet/parquet.thrift diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/parquet.thrift b/dataframe-parquet/src/DataFrame/IO/Parquet/parquet.thrift deleted file mode 100644 index 71f0b6e6..00000000 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/parquet.thrift +++ /dev/null @@ -1,1486 +0,0 @@ -/** - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * File format description for the parquet file format - */ -namespace cpp parquet -namespace java org.apache.parquet.format - -/** - * Types supported by Parquet. These types are intended to be used in combination - * with the encodings to control the on disk storage format. - * For example INT16 is not included as a type since a good encoding of INT32 - * would handle this. - */ -enum Type { - BOOLEAN = 0; - INT32 = 1; - INT64 = 2; - INT96 = 3; // deprecated, new Parquet writers should not write data in INT96 - FLOAT = 4; - DOUBLE = 5; - BYTE_ARRAY = 6; - FIXED_LEN_BYTE_ARRAY = 7; -} - -/** - * DEPRECATED: Common types used by frameworks (e.g. Hive, Pig) using parquet. - * ConvertedType is superseded by LogicalType. This enum should not be extended. - * - * See LogicalTypes.md for conversion between ConvertedType and LogicalType. - */ -enum ConvertedType { - /** a BYTE_ARRAY actually contains UTF8 encoded chars */ - UTF8 = 0; - - /** a map is converted as an optional field containing a repeated key/value pair */ - MAP = 1; - - /** a key/value pair is converted into a group of two fields */ - MAP_KEY_VALUE = 2; - - /** a list is converted into an optional field containing a repeated field for its - * values */ - LIST = 3; - - /** an enum is converted into a BYTE_ARRAY field */ - ENUM = 4; - - /** - * A decimal value. - * - * This may be used to annotate BYTE_ARRAY or FIXED_LEN_BYTE_ARRAY primitive - * types. The underlying byte array stores the unscaled value encoded as two's - * complement using big-endian byte order (the most significant byte is the - * zeroth element). The value of the decimal is the value * 10^{-scale}. - * - * This must be accompanied by a (maximum) precision and a scale in the - * SchemaElement. The precision specifies the number of digits in the decimal - * and the scale stores the location of the decimal point. For example 1.23 - * would have precision 3 (3 total digits) and scale 2 (the decimal point is - * 2 digits over). - */ - DECIMAL = 5; - - /** - * A Date - * - * Stored as days since Unix epoch, encoded as the INT32 physical type. - * - */ - DATE = 6; - - /** - * A time - * - * The total number of milliseconds since midnight. The value is stored - * as an INT32 physical type. - */ - TIME_MILLIS = 7; - - /** - * A time. - * - * The total number of microseconds since midnight. The value is stored as - * an INT64 physical type. - */ - TIME_MICROS = 8; - - /** - * A date/time combination - * - * Date and time recorded as milliseconds since the Unix epoch. Recorded as - * a physical type of INT64. - */ - TIMESTAMP_MILLIS = 9; - - /** - * A date/time combination - * - * Date and time recorded as microseconds since the Unix epoch. The value is - * stored as an INT64 physical type. - */ - TIMESTAMP_MICROS = 10; - - - /** - * An unsigned integer value. - * - * The number describes the maximum number of meaningful data bits in - * the stored value. 8, 16 and 32 bit values are stored using the - * INT32 physical type. 64 bit values are stored using the INT64 - * physical type. - * - */ - UINT_8 = 11; - UINT_16 = 12; - UINT_32 = 13; - UINT_64 = 14; - - /** - * A signed integer value. - * - * The number describes the maximum number of meaningful data bits in - * the stored value. 8, 16 and 32 bit values are stored using the - * INT32 physical type. 64 bit values are stored using the INT64 - * physical type. - * - */ - INT_8 = 15; - INT_16 = 16; - INT_32 = 17; - INT_64 = 18; - - /** - * An embedded JSON document - * - * A JSON document embedded within a single UTF8 column. - */ - JSON = 19; - - /** - * An embedded BSON document - * - * A BSON document embedded within a single BYTE_ARRAY column. - */ - BSON = 20; - - /** - * An interval of time - * - * This type annotates data stored as a FIXED_LEN_BYTE_ARRAY of length 12 - * This data is composed of three separate little endian unsigned - * integers. Each stores a component of a duration of time. The first - * integer identifies the number of months associated with the duration, - * the second identifies the number of days associated with the duration - * and the third identifies the number of milliseconds associated with - * the provided duration. This duration of time is independent of any - * particular timezone or date. - */ - INTERVAL = 21; -} - -/** - * Representation of Schemas - */ -enum FieldRepetitionType { - /** This field is required (can not be null) and each row has exactly 1 value. */ - REQUIRED = 0; - - /** The field is optional (can be null) and each row has 0 or 1 values. */ - OPTIONAL = 1; - - /** The field is repeated and can contain 0 or more values */ - REPEATED = 2; -} - -/** - * A structure for capturing metadata for estimating the unencoded, - * uncompressed size of data written. This is useful for readers to estimate - * how much memory is needed to reconstruct data in their memory model and for - * fine grained filter pushdown on nested structures (the histograms contained - * in this structure can help determine the number of nulls at a particular - * nesting level and maximum length of lists). - */ -struct SizeStatistics { - /** - * The number of physical bytes stored for BYTE_ARRAY data values assuming - * no encoding. This is exclusive of the bytes needed to store the length of - * each byte array. In other words, this field is equivalent to the `(size - * of PLAIN-ENCODING the byte array values) - (4 bytes * number of values - * written)`. To determine unencoded sizes of other types readers can use - * schema information multiplied by the number of non-null and null values. - * The number of null/non-null values can be inferred from the histograms - * below. - * - * For example, if a column chunk is dictionary-encoded with dictionary - * ["a", "bc", "cde"], and a data page contains the indices [0, 0, 1, 2], - * then this value for that data page should be 7 (1 + 1 + 2 + 3). - * - * This field should only be set for types that use BYTE_ARRAY as their - * physical type. - */ - 1: optional i64 unencoded_byte_array_data_bytes; - /** - * When present, there is expected to be one element corresponding to each - * repetition (i.e. size=max repetition_level+1) where each element - * represents the number of times the repetition level was observed in the - * data. - * - * This field may be omitted if max_repetition_level is 0 without loss - * of information. - **/ - 2: optional list repetition_level_histogram; - /** - * Same as repetition_level_histogram except for definition levels. - * - * This field may be omitted if max_definition_level is 0 or 1 without - * loss of information. - **/ - 3: optional list definition_level_histogram; -} - -/** - * Bounding box for GEOMETRY or GEOGRAPHY type in the representation of min/max - * value pair of coordinates from each axis. - */ -struct BoundingBox { - 1: required double xmin; - 2: required double xmax; - 3: required double ymin; - 4: required double ymax; - 5: optional double zmin; - 6: optional double zmax; - 7: optional double mmin; - 8: optional double mmax; -} - -/** Statistics specific to Geometry and Geography logical types */ -struct GeospatialStatistics { - /** A bounding box of geospatial instances */ - 1: optional BoundingBox bbox; - /** Geospatial type codes of all instances, or an empty list if not known */ - 2: optional list geospatial_types; -} - -/** - * Statistics per row group and per page - * All fields are optional. - */ -struct Statistics { - /** - * DEPRECATED: min and max value of the column. Use min_value and max_value. - * - * Values are encoded using PLAIN encoding, except that variable-length byte - * arrays do not include a length prefix. - * - * These fields encode min and max values determined by signed comparison - * only. New files should use the correct order for a column's logical type - * and store the values in the min_value and max_value fields. - * - * To support older readers, these may be set when the column order is - * signed. - */ - 1: optional binary max; - 2: optional binary min; - /** - * Count of null values in the column. - * - * Writers SHOULD always write this field even if it is zero (i.e. no null value) - * or the column is not nullable. - * Readers MUST distinguish between null_count not being present and null_count == 0. - * If null_count is not present, readers MUST NOT assume null_count == 0. - */ - 3: optional i64 null_count; - /** count of distinct values occurring */ - 4: optional i64 distinct_count; - /** - * Lower and upper bound values for the column, determined by its ColumnOrder. - * - * These may be the actual minimum and maximum values found on a page or column - * chunk, but can also be (more compact) values that do not exist on a page or - * column chunk. For example, instead of storing "Blart Versenwald III", a writer - * may set min_value="B", max_value="C". Such more compact values must still be - * valid values within the column's logical type. - * - * Values are encoded using PLAIN encoding, except that variable-length byte - * arrays do not include a length prefix. - */ - 5: optional binary max_value; - 6: optional binary min_value; - /** If true, max_value is the actual maximum value for a column */ - 7: optional bool is_max_value_exact; - /** If true, min_value is the actual minimum value for a column */ - 8: optional bool is_min_value_exact; - /** - * Count of NaN values in the column; only present if physical type is FLOAT - * or DOUBLE, or logical type is FLOAT16. - * If this field is not present, readers MUST assume NaNs may be present - * (i.e. MUST assume nan_count > 0 and MAY NOT assume nan_count == 0). - */ - 9: optional i64 nan_count; -} - -/** Empty structs to use as logical type annotations */ -struct StringType {} // allowed for BYTE_ARRAY, must be encoded with UTF-8 -struct UUIDType {} // allowed for FIXED[16], must be encoded as raw UUID bytes -struct MapType {} // see LogicalTypes.md -struct ListType {} // see LogicalTypes.md -struct EnumType {} // allowed for BYTE_ARRAY, must be encoded with UTF-8 -struct DateType {} // allowed for INT32 -struct Float16Type {} // allowed for FIXED[2], must be encoded as raw FLOAT16 bytes (see LogicalTypes.md) - -/** - * Logical type to annotate a column that is always null. - * - * Sometimes when discovering the schema of existing data, values are always - * null and the physical type can't be determined. This annotation signals - * the case where the physical type was guessed from all null values. - */ -struct NullType {} // allowed for any physical type, only null values stored - -/** - * Decimal logical type annotation - * - * Scale must be zero or a positive integer less than or equal to the precision. - * Precision must be a non-zero positive integer. - * - * To maintain forward-compatibility in v1, implementations using this logical - * type must also set scale and precision on the annotated SchemaElement. - * - * Allowed for physical types: INT32, INT64, FIXED_LEN_BYTE_ARRAY, and BYTE_ARRAY. - */ -struct DecimalType { - 1: required i32 scale - 2: required i32 precision -} - -/** Time units for logical types */ -struct MilliSeconds {} -struct MicroSeconds {} -struct NanoSeconds {} -union TimeUnit { - 1: MilliSeconds MILLIS - 2: MicroSeconds MICROS - 3: NanoSeconds NANOS -} - -/** - * Timestamp logical type annotation - * - * Allowed for physical types: INT64 - */ -struct TimestampType { - 1: required bool isAdjustedToUTC - 2: required TimeUnit unit -} - -/** - * Time logical type annotation - * - * Allowed for physical types: INT32 (millis), INT64 (micros, nanos) - */ -struct TimeType { - 1: required bool isAdjustedToUTC - 2: required TimeUnit unit -} - -/** - * Integer logical type annotation - * - * bitWidth must be 8, 16, 32, or 64. - * - * Allowed for physical types: INT32, INT64 - */ -struct IntType { - 1: required i8 bitWidth - 2: required bool isSigned -} - -/** - * Embedded JSON logical type annotation - * - * Allowed for physical types: BYTE_ARRAY - */ -struct JsonType { -} - -/** - * Embedded BSON logical type annotation - * - * Allowed for physical types: BYTE_ARRAY - */ -struct BsonType { -} - -/** - * Embedded Variant logical type annotation - */ -struct VariantType { - // The version of the variant specification that the variant was - // written with. - 1: optional i8 specification_version -} - -/** Edge interpolation algorithm for Geography logical type */ -enum EdgeInterpolationAlgorithm { - SPHERICAL = 0; - VINCENTY = 1; - THOMAS = 2; - ANDOYER = 3; - KARNEY = 4; -} - -/** - * Embedded Geometry logical type annotation - * - * Geospatial features in the Well-Known Binary (WKB) format and `edges` interpolation - * is always linear/planar. - * - * A custom CRS can be set by the crs field. If unset, it defaults to "OGC:CRS84", - * which means that the geometries must be stored in longitude, latitude based on - * the WGS84 datum. - * - * Allowed for physical type: BYTE_ARRAY. - * - * See Geospatial.md for details. - */ -struct GeometryType { - 1: optional string crs; -} - -/** - * Embedded Geography logical type annotation - * - * Geospatial features in the WKB format with an explicit (non-linear/non-planar) - * `edges` interpolation algorithm. - * - * A custom geographic CRS can be set by the crs field, where longitudes are - * bound by [-180, 180] and latitudes are bound by [-90, 90]. If unset, the CRS - * defaults to "OGC:CRS84". - * - * An optional algorithm can be set to correctly interpret `edges` interpolation - * of the geometries. If unset, the algorithm defaults to SPHERICAL. - * - * Allowed for physical type: BYTE_ARRAY. - * - * See Geospatial.md for details. - */ -struct GeographyType { - 1: optional string crs; - 2: optional EdgeInterpolationAlgorithm algorithm; -} - -/** - * File logical type annotation - * - * Annotates a group that represents a reference to a file, or to a range of - * bytes that may be stored inline, elsewhere in this file, or in an external - * file. - * - * See LogicalTypes.md for details. - */ -struct FileType { -} - -/** - * LogicalType annotations to replace ConvertedType. - * - * To maintain compatibility, implementations using LogicalType for a - * SchemaElement must also set the corresponding ConvertedType (if any) - * from the following table. - */ -union LogicalType { - 1: StringType STRING // use ConvertedType UTF8 - 2: MapType MAP // use ConvertedType MAP - 3: ListType LIST // use ConvertedType LIST - 4: EnumType ENUM // use ConvertedType ENUM - 5: DecimalType DECIMAL // use ConvertedType DECIMAL + SchemaElement.{scale, precision} - 6: DateType DATE // use ConvertedType DATE - - // use ConvertedType TIME_MICROS for TIME(isAdjustedToUTC = *, unit = MICROS) - // use ConvertedType TIME_MILLIS for TIME(isAdjustedToUTC = *, unit = MILLIS) - 7: TimeType TIME - - // use ConvertedType TIMESTAMP_MICROS for TIMESTAMP(isAdjustedToUTC = *, unit = MICROS) - // use ConvertedType TIMESTAMP_MILLIS for TIMESTAMP(isAdjustedToUTC = *, unit = MILLIS) - 8: TimestampType TIMESTAMP - - // 9: reserved for INTERVAL - 10: IntType INTEGER // use ConvertedType INT_* or UINT_* - 11: NullType UNKNOWN // no compatible ConvertedType - 12: JsonType JSON // use ConvertedType JSON - 13: BsonType BSON // use ConvertedType BSON - 14: UUIDType UUID // no compatible ConvertedType - 15: Float16Type FLOAT16 // no compatible ConvertedType - 16: VariantType VARIANT // no compatible ConvertedType - 17: GeometryType GEOMETRY // no compatible ConvertedType - 18: GeographyType GEOGRAPHY // no compatible ConvertedType - 19: FileType FILE // no compatible ConvertedType -} - -/** - * Represents an element inside a schema definition. - * - if it is a group (inner node) then type is undefined and num_children is defined - * - if it is a primitive type (leaf) then type is defined and num_children is undefined - * the nodes are listed in depth first traversal order. - */ -struct SchemaElement { - /** Data type for this field. Not set if the current element is a non-leaf node */ - 1: optional Type type; - - /** If type is FIXED_LEN_BYTE_ARRAY, this is the byte length of the values. - * Otherwise, if specified, this is the maximum bit length to store any of the values. - * (e.g. a low cardinality INT col could have this set to 3). Note that this is - * in the schema, and therefore fixed for the entire file. - */ - 2: optional i32 type_length; - - /** repetition of the field. The root of the schema does not have a repetition_type. - * All other nodes must have one */ - 3: optional FieldRepetitionType repetition_type; - - /** Name of the field in the schema */ - 4: required string name; - - /** Nested fields. Since thrift does not support nested fields, - * the nesting is flattened to a single list by a depth-first traversal. - * The children count is used to construct the nested relationship. - * This field is not set when the element is a primitive type - */ - 5: optional i32 num_children; - - /** - * DEPRECATED: When the schema is the result of a conversion from another model. - * Used to record the original type to help with cross conversion. - * - * This is superseded by logicalType. - */ - 6: optional ConvertedType converted_type; - - /** - * DEPRECATED: Used when this column contains decimal data. - * See the DECIMAL converted type for more details. - * - * This is superseded by using the DecimalType annotation in logicalType. - */ - 7: optional i32 scale - 8: optional i32 precision - - /** When the original schema supports field ids, this will save the - * original field id in the parquet schema - */ - 9: optional i32 field_id; - - /** - * The logical type of this SchemaElement - * - * LogicalType replaces ConvertedType, but ConvertedType is still required - * for some logical types to ensure forward-compatibility in format v1. - */ - 10: optional LogicalType logicalType -} - -/** - * Encodings supported by Parquet. Not all encodings are valid for all types. These - * enums are also used to specify the encoding of definition and repetition levels. - * See the accompanying doc for the details of the more complicated encodings. - */ -enum Encoding { - /** Default encoding. - * BOOLEAN - 1 bit per value. 0 is false; 1 is true. - * INT32 - 4 bytes per value. Stored as little-endian. - * INT64 - 8 bytes per value. Stored as little-endian. - * FLOAT - 4 bytes per value. IEEE. Stored as little-endian. - * DOUBLE - 8 bytes per value. IEEE. Stored as little-endian. - * BYTE_ARRAY - 4 byte length stored as little endian, followed by bytes. - * FIXED_LEN_BYTE_ARRAY - Just the bytes. - */ - PLAIN = 0; - - /** Group VarInt encoding for INT32/INT64. - * This encoding is deprecated. It was never used. - */ - // GROUP_VAR_INT = 1; - - /** - * DEPRECATED: Dictionary encoding. The values in the dictionary are encoded in the - * plain type. - * For a data page use RLE_DICTIONARY instead. - * For a Dictionary page use PLAIN instead. - */ - PLAIN_DICTIONARY = 2; - - /** Group packed run length encoding. Usable for definition/repetition levels - * encoding and Booleans (on one bit: 0 is false; 1 is true.) - */ - RLE = 3; - - /** DEPRECATED: Bit packed encoding. This can only be used if the data has a known max - * width. Usable for definition/repetition levels encoding. - * Superseded by RLE (which is a hybrid of RLE and bit packing); see Encodings.md. - */ - BIT_PACKED = 4; - - /** Delta encoding for integers. This can be used for int columns and works best - * on sorted data - */ - DELTA_BINARY_PACKED = 5; - - /** Encoding for byte arrays to separate the length values and the data. The lengths - * are encoded using DELTA_BINARY_PACKED - */ - DELTA_LENGTH_BYTE_ARRAY = 6; - - /** Incremental-encoded byte array. Prefix lengths are encoded using DELTA_BINARY_PACKED. - * Suffixes are stored as delta length byte arrays. - */ - DELTA_BYTE_ARRAY = 7; - - /** Dictionary encoding: the ids are encoded using the RLE encoding - */ - RLE_DICTIONARY = 8; - - /** Encoding for fixed-width data (FLOAT, DOUBLE, INT32, INT64, FIXED_LEN_BYTE_ARRAY). - K byte-streams are created where K is the size in bytes of the data type. - The individual bytes of a value are scattered to the corresponding stream and - the streams are concatenated. - This itself does not reduce the size of the data but can lead to better compression - afterwards. - - Added in 2.8 for FLOAT and DOUBLE. - Support for INT32, INT64 and FIXED_LEN_BYTE_ARRAY added in 2.11. - */ - BYTE_STREAM_SPLIT = 9; - - /** Adaptive Lossless floating-Point (ALP) encoding for FLOAT and DOUBLE. - Losslessly converts decimal-like floating-point values to integers via - decimal scaling, then applies Frame of Reference (FOR) encoding and - bit-packing; values that cannot be converted losslessly are stored as - exceptions. See Encodings.md for the detailed specification. - */ - ALP = 10; -} - -/** - * Supported compression algorithms. - * - * Codecs added in format version X.Y can be read by readers based on X.Y and later. - * Codec support may vary between readers based on the format version and - * libraries available at runtime. - * - * See Compression.md for a detailed specification of these algorithms. - */ -enum CompressionCodec { - UNCOMPRESSED = 0; - SNAPPY = 1; - GZIP = 2; - LZO = 3; - BROTLI = 4; // Added in 2.4 - LZ4 = 5; // DEPRECATED (Added in 2.4) - ZSTD = 6; // Added in 2.4 - LZ4_RAW = 7; // Added in 2.9 -} - -enum PageType { - DATA_PAGE = 0; - INDEX_PAGE = 1; - DICTIONARY_PAGE = 2; - DATA_PAGE_V2 = 3; -} - -/** - * Enum to annotate whether lists of min/max elements inside ColumnIndex - * are ordered and if so, in which direction. - */ -enum BoundaryOrder { - UNORDERED = 0; - ASCENDING = 1; - DESCENDING = 2; -} - -/** Data page header */ -struct DataPageHeader { - /** - * Number of values, including NULLs, in this data page. - * - * If an OffsetIndex is present, a page must begin at a row - * boundary (repetition_level = 0). Otherwise, pages may begin - * within a row (repetition_level > 0). - **/ - 1: required i32 num_values - - /** Encoding used for this data page **/ - 2: required Encoding encoding - - /** Encoding used for definition levels **/ - 3: required Encoding definition_level_encoding; - - /** Encoding used for repetition levels **/ - 4: required Encoding repetition_level_encoding; - - /** Optional statistics for the data in this page **/ - 5: optional Statistics statistics; -} - -struct IndexPageHeader { - // TODO -} - -/** - * The dictionary page must be placed at the first position of the column chunk - * if it is partly or completely dictionary encoded. At most one dictionary page - * can be placed in a column chunk. - **/ -struct DictionaryPageHeader { - /** Number of values in the dictionary **/ - 1: required i32 num_values; - - /** Encoding using this dictionary page **/ - 2: required Encoding encoding - - /** If true, the entries in the dictionary are sorted in ascending order **/ - 3: optional bool is_sorted; -} - -/** - * Alternate page format allowing reading levels without decompressing the data - * Repetition and definition levels are uncompressed - * The remaining section containing the data is compressed if is_compressed is true - * - * Implementation note - this header is not necessarily a strict improvement over - * `DataPageHeader` (in particular the original header might provide better compression - * in some scenarios). Page indexes require pages to start and end at row boundaries, - * regardless of which page header is used. - **/ -struct DataPageHeaderV2 { - /** Number of values, including NULLs, in this data page. **/ - 1: required i32 num_values - /** Number of NULL values, in this data page. - Number of non-null = num_values - num_nulls which is also the number of values in the data section **/ - 2: required i32 num_nulls - /** - * Number of rows in this data page. Every page must begin at a - * row boundary (repetition_level = 0): rows must **not** be - * split across page boundaries when using V2 data pages. - **/ - 3: required i32 num_rows - /** Encoding used for data in this page **/ - 4: required Encoding encoding - - // repetition levels and definition levels are always using RLE (without size in it) - - /** Length of the definition levels */ - 5: required i32 definition_levels_byte_length; - /** Length of the repetition levels */ - 6: required i32 repetition_levels_byte_length; - - /** Whether the values are compressed. - Which means the section of the page between - definition_levels_byte_length + repetition_levels_byte_length and compressed_page_size (included) - is compressed with the compression_codec. - If missing it is considered compressed */ - 7: optional bool is_compressed = true; - - /** Optional statistics for the data in this page **/ - 8: optional Statistics statistics; -} - -/** Block-based algorithm type annotation. **/ -struct SplitBlockAlgorithm {} -/** The algorithm used in Bloom filter. **/ -union BloomFilterAlgorithm { - /** Block-based Bloom filter. **/ - 1: SplitBlockAlgorithm BLOCK; -} - -/** Hash strategy type annotation. xxHash is an extremely fast non-cryptographic hash - * algorithm. It uses 64 bits version of xxHash. - **/ -struct XxHash {} - -/** - * The hash function used in Bloom filter. This function takes the hash of a column value - * using plain encoding. - **/ -union BloomFilterHash { - /** xxHash Strategy. **/ - 1: XxHash XXHASH; -} - -/** - * The compression used in the Bloom filter. - **/ -struct Uncompressed {} -union BloomFilterCompression { - 1: Uncompressed UNCOMPRESSED; -} - -/** - * Bloom filter header is stored at beginning of Bloom filter data of each column - * and followed by its bitset. - **/ -struct BloomFilterHeader { - /** The size of bitset in bytes **/ - 1: required i32 numBytes; - /** The algorithm for setting bits. **/ - 2: required BloomFilterAlgorithm algorithm; - /** The hash function used for Bloom filter. **/ - 3: required BloomFilterHash hash; - /** The compression used in the Bloom filter **/ - 4: required BloomFilterCompression compression; -} - -struct PageHeader { - /** the type of the page: indicates which of the *_header fields is set **/ - 1: required PageType type - - /** Uncompressed page size in bytes (not including this header) **/ - 2: required i32 uncompressed_page_size - - /** Compressed (and potentially encrypted) page size in bytes, not including this header **/ - 3: required i32 compressed_page_size - - /** The 32-bit CRC checksum for the page, to be calculated as follows: - * - * - The standard CRC32 algorithm is used (with polynomial 0x04C11DB7, - * the same as in e.g. GZIP). - * - All page types can have a CRC (v1 and v2 data pages, dictionary pages, - * etc.). - * - The CRC is computed on the serialization binary representation of the page - * (as written to disk), excluding the page header. For example, for v1 - * data pages, the CRC is computed on the concatenation of repetition levels, - * definition levels and column values (optionally compressed, optionally - * encrypted). - * - The CRC computation therefore takes place after any compression - * and encryption steps, if any. - * - * If enabled, this allows for disabling checksumming in HDFS if only a few - * pages need to be read. - */ - 4: optional i32 crc - - // Headers for page specific data. One only will be set. - 5: optional DataPageHeader data_page_header; - 6: optional IndexPageHeader index_page_header; - 7: optional DictionaryPageHeader dictionary_page_header; - 8: optional DataPageHeaderV2 data_page_header_v2; -} - -/** - * Wrapper struct to store key values - */ - struct KeyValue { - 1: required string key - 2: optional string value -} - -/** - * Sort order within a RowGroup of a leaf column - */ -struct SortingColumn { - /** The ordinal position of the column (in this row group) **/ - 1: required i32 column_idx - - /** If true, indicates this column is sorted in descending order. **/ - 2: required bool descending - - /** If true, nulls will come before non-null values, otherwise, - * nulls go at the end. */ - 3: required bool nulls_first -} - -/** - * statistics of a given page type and encoding - */ -struct PageEncodingStats { - - /** the page type (data/dic/...) **/ - 1: required PageType page_type; - - /** encoding of the page **/ - 2: required Encoding encoding; - - /** number of pages of this type with this encoding **/ - 3: required i32 count; - -} - -/** - * Description for column metadata - */ -struct ColumnMetaData { - /** Type of this column **/ - 1: required Type type - - /** Set of all encodings used for this column. The purpose is to validate - * whether we can decode those pages. **/ - 2: required list encodings - - /** Path in schema **/ - 3: required list path_in_schema - - /** Compression codec **/ - 4: required CompressionCodec codec - - /** Number of values in this column **/ - 5: required i64 num_values - - /** total byte size of all uncompressed pages in this column chunk (including the headers) **/ - 6: required i64 total_uncompressed_size - - /** total byte size of all compressed, and potentially encrypted, pages - * in this column chunk (including the headers) **/ - 7: required i64 total_compressed_size - - /** Optional key/value metadata **/ - 8: optional list key_value_metadata - - /** Byte offset from beginning of file to first data page **/ - 9: required i64 data_page_offset - - /** Byte offset from beginning of file to root index page **/ - 10: optional i64 index_page_offset - - /** Byte offset from the beginning of file to first (only) dictionary page **/ - 11: optional i64 dictionary_page_offset - - /** optional statistics for this column chunk */ - 12: optional Statistics statistics; - - /** Set of all encodings used for pages in this column chunk. - * This information can be used to determine if all data pages are - * dictionary encoded for example **/ - 13: optional list encoding_stats; - - /** Byte offset from beginning of file to Bloom filter data. **/ - 14: optional i64 bloom_filter_offset; - - /** Size of Bloom filter data including the serialized header, in bytes. - * Added in 2.10 so readers may not read this field from old files and - * it can be obtained after the BloomFilterHeader has been deserialized. - * Writers should write this field so readers can read the bloom filter - * in a single I/O. - */ - 15: optional i32 bloom_filter_length; - - /** - * Optional statistics to help estimate total memory when converted to in-memory - * representations. The histograms contained in these statistics can - * also be useful in some cases for more fine-grained nullability/list length - * filter pushdown. - */ - 16: optional SizeStatistics size_statistics; - - /** Optional statistics specific for Geometry and Geography logical types */ - 17: optional GeospatialStatistics geospatial_statistics; -} - -struct EncryptionWithFooterKey { -} - -struct EncryptionWithColumnKey { - /** Column path in schema **/ - 1: required list path_in_schema - - /** Retrieval metadata of column encryption key **/ - 2: optional binary key_metadata -} - -union ColumnCryptoMetaData { - 1: EncryptionWithFooterKey ENCRYPTION_WITH_FOOTER_KEY - 2: EncryptionWithColumnKey ENCRYPTION_WITH_COLUMN_KEY -} - -struct ColumnChunk { - /** File where column data is stored. If not set, assumed to be same file as - * metadata. This path is relative to the current file. - * - * As of December 2025, the only known use-case for this field is writing summary - * parquet files (i.e. "_metadata" files). These files consolidate footers from - * multiple parquet files to allow for efficient reading of footers to avoid file - * listing costs and prune out files that do not need to be read based on statistics. - * - * These files do not appear to have ever been formally specified in the specification. - * and are potentially problematic from a correctness perspective [1]. - * - * [1] https://lists.apache.org/thread/ootf2kmyg3p01b1bvplpvp4ftd1bt72d - * - * There is no other known usage of this field. Specifically, there are no known - * reference implementations that will read externally stored column data if this field is populated - * within a standard parquet file. Making use of the field for this purpose is - * not considered part of the Parquet specification. - **/ - 1: optional string file_path - - /** DEPRECATED: Byte offset in file_path to the ColumnMetaData - * - * Past use of this field has been inconsistent, with some implementations - * using it to point to the ColumnMetaData and some using it to point to - * the first page in the column chunk. In many cases, the ColumnMetaData at this - * location is wrong. This field is now deprecated and should not be used. - * Writers should set this field to 0 if no ColumnMetaData has been written outside - * the footer. - */ - 2: required i64 file_offset = 0 - - /** Column metadata for this chunk. Some writers may also replicate this at the - * location pointed to by file_path/file_offset. - * Note: while marked as optional, this field is in fact required by most major - * Parquet implementations. As such, writers MUST populate this field. - **/ - 3: optional ColumnMetaData meta_data - - /** File offset of ColumnChunk's OffsetIndex **/ - 4: optional i64 offset_index_offset - - /** Size of ColumnChunk's OffsetIndex, in bytes **/ - 5: optional i32 offset_index_length - - /** File offset of ColumnChunk's ColumnIndex **/ - 6: optional i64 column_index_offset - - /** Size of ColumnChunk's ColumnIndex, in bytes **/ - 7: optional i32 column_index_length - - /** Crypto metadata of encrypted columns **/ - 8: optional ColumnCryptoMetaData crypto_metadata - - /** Encrypted column metadata for this chunk **/ - 9: optional binary encrypted_column_metadata -} - -struct RowGroup { - /** Metadata for each column chunk in this row group. - * This list must have the same order as the SchemaElement list in FileMetaData. - **/ - 1: required list columns - - /** Total byte size of all the uncompressed column data in this row group **/ - 2: required i64 total_byte_size - - /** Number of rows in this row group **/ - 3: required i64 num_rows - - /** If set, specifies a sort ordering of the rows in this RowGroup. - * The sorting columns can be a subset of all the columns. - */ - 4: optional list sorting_columns - - /** Byte offset from beginning of file to first page (data or dictionary) - * in this row group **/ - 5: optional i64 file_offset - - /** Total byte size of all compressed (and potentially encrypted) column data - * in this row group **/ - 6: optional i64 total_compressed_size - - /** Row group ordinal in the file **/ - 7: optional i16 ordinal -} - -/** Empty struct to signal the order defined by the physical or logical type */ -struct TypeDefinedOrder {} - -/** Empty struct to signal IEEE 754 total order for floating point types */ -struct IEEE754TotalOrder {} - -/** Empty struct to signal chronological ordering of physical type INT96 */ -struct Int96TimestampOrder {} - -/** - * Union to specify the order used for the min_value and max_value fields for a - * column. This union takes the role of an enhanced enum that allows rich - * elements (which will be needed for a collation-based ordering in the future). - * - * Possible values are: - * * TypeDefinedOrder - the column uses the order defined by its logical or - * physical type (if there is no logical type). - * * IEEE754TotalOrder - the floating point column uses IEEE 754 total order. - * - * * Int96TimestampOrder - the INT96 column uses chronological timestamp order. - * - * If the reader does not support the value of this union, min and max stats - * for this column should be ignored. - */ -union ColumnOrder { - - /** - * The sort orders for logical types are: - * UTF8 - unsigned byte-wise comparison - * INT8 - signed comparison - * INT16 - signed comparison - * INT32 - signed comparison - * INT64 - signed comparison - * UINT8 - unsigned comparison - * UINT16 - unsigned comparison - * UINT32 - unsigned comparison - * UINT64 - unsigned comparison - * DECIMAL - signed comparison of the represented value - * DATE - signed comparison - * FLOAT16 - signed comparison of the represented value (*) - * TIME_MILLIS - signed comparison - * TIME_MICROS - signed comparison - * TIMESTAMP_MILLIS - signed comparison - * TIMESTAMP_MICROS - signed comparison - * INTERVAL - undefined - * JSON - unsigned byte-wise comparison - * BSON - unsigned byte-wise comparison - * ENUM - unsigned byte-wise comparison - * LIST - undefined - * MAP - undefined - * VARIANT - undefined - * GEOMETRY - undefined - * GEOGRAPHY - undefined - * FILE - undefined - * - * In the absence of logical types, the sort order is determined by the physical type: - * BOOLEAN - false, true - * INT32 - signed comparison - * INT64 - signed comparison - * INT96 (only used for legacy timestamps) - depends on sort order (+) - * FLOAT - signed comparison of the represented value (*) - * DOUBLE - signed comparison of the represented value (*) - * BYTE_ARRAY - unsigned byte-wise comparison - * FIXED_LEN_BYTE_ARRAY - unsigned byte-wise comparison - * - * (+) While the INT96 type has been deprecated, at the time of writing it is - * still used in many legacy systems. It is optional for writers to emit - * statistics for INT96 columns. Writers that emit stats for such columns - * should use the INT96_TIMESTAMP_ORDER for this type and order the values - * according to the legacy rules: - * - compare the last 4 bytes (days) as a little-endian 32-bit signed integer - * - if equal last 4 bytes, compare the first 8 bytes as a little-endian - * 64-bit signed integer (nanos) - * If TYPE_ORDER is used for an INT96 column, readers should ignore all statistics - * (`min`/`max` fields in `Statistics` and `min_values`/`max_values` fields in - * `ColumnIndex`) for that column. - * - * (*) Because TYPE_ORDER is ambiguous for floating point types due to - * underspecified handling of NaN and -0/+0, it is recommended that writers - * use IEEE_754_TOTAL_ORDER for these types. - * - * If TYPE_ORDER is used for floating point types, then the following - * compatibility rules should be applied when reading statistics: - * - If the min is a NaN, it should be ignored. - * - If the max is a NaN, it should be ignored. - * - If the nan_count field is set, a reader can compute - * nan_count + null_count == num_values to deduce whether all non-null - * values are NaN. - * - If the min is +0, the row group may contain -0 values as well. - * - If the max is -0, the row group may contain +0 values as well. - * - When looking for NaN values, min and max should be ignored. - * If the nan_count field is set, it can be used to check whether - * NaNs are present. - * - * When writing page or column chunk statistics for columns with - * TYPE_ORDER order, the following rules must be followed: - * - The nan_count field must be set for floating point types, even if - * it is zero. - * - If the nan_count field is set, min and max statistics fields, when - * present, must not contain NaN values and must be computed from - * non-NaN values only. This signals to readers that the min and max - * statistics are reliable for non-NaN values. - * - If all non-null values are NaN, min and max statistics must not be - * written. - * - If the computed max value is zero (whether negative or positive), - * `+0.0` should be written into the max statistics field. - * - If the computed min value is zero (whether negative or positive), - * `-0.0` should be written into the min statistics field. - * - * When writing column indexes for columns with TYPE_ORDER order, the - * following rules must be followed: - * - NaNs must not be written to min_values or max_values. - * - If all non-null values of a page are NaN, a column index must not - * be written for this column chunk because min_values and max_values - * are required. - * - If the computed max value is zero (whether negative or positive), - * `+0.0` should be written into the corresponding max_values entry. - * - If the computed min value is zero (whether negative or positive), - * `-0.0` should be written into the corresponding min_values entry. - */ - 1: TypeDefinedOrder TYPE_ORDER; - - /* - * The floating point type is ordered according to the totalOrder predicate, - * as defined in section 5.10 of IEEE-754 (2008 revision). Only columns of - * physical type FLOAT or DOUBLE, or logical type FLOAT16 may use this ordering. - * - * Intuitively, this orders floats mathematically, but defines -0 to be less - * than +0, -NaN to be less than anything else, and +NaN to be greater than - * anything else. It also defines an order between different bit representations - * of the same value. - * - * When writing statistics for columns with IEEE_754_TOTAL_ORDER order, then - * following rules must be followed: - * - Writing the nan_count field is mandatory when using this ordering. - * - Min and max statistics must contain the smallest and largest non-NaN - * values respectively, or if all non-null values are NaN, the smallest and - * largest NaN values as defined by IEEE 754 total order. - * - * When reading statistics for columns with this order, the following rules - * should be followed: - * - Readers should consult the nan_count field to determine whether NaNs - * are present. - * - A reader can compute nan_count + null_count == num_values to deduce - * whether all non-null values are NaN. In the page index, which does not - * have a num_values field, the presence of a NaN value in min_values - * or max_values indicates that all non-null values are NaN. - */ - 2: IEEE754TotalOrder IEEE_754_TOTAL_ORDER; - - /* - * The INT96 timestamp type is ordered chronologically. Only columns of - * physical type INT96 may use this ordering. - */ - 3: Int96TimestampOrder INT96_TIMESTAMP_ORDER; -} - -struct PageLocation { - /** Offset of the page in the file **/ - 1: required i64 offset - - /** - * Size of the page, including header. Equal to the sum of the page's - * PageHeader.compressed_page_size and the size of the serialized PageHeader. - */ - 2: required i32 compressed_page_size - - /** - * Index within the RowGroup of the first row of the page. When an - * OffsetIndex is present, pages must begin on row boundaries - * (repetition_level = 0). - */ - 3: required i64 first_row_index -} - -/** - * Optional offsets for each data page in a ColumnChunk. - * - * Forms part of the page index, along with ColumnIndex. - * - * OffsetIndex may be present even if ColumnIndex is not. - */ -struct OffsetIndex { - /** - * PageLocations, ordered by increasing PageLocation.offset. It is required - * that page_locations[i].first_row_index < page_locations[i+1].first_row_index. - */ - 1: required list page_locations - /** - * Unencoded/uncompressed size for BYTE_ARRAY types. - * - * See documentation for unencoded_byte_array_data_bytes in SizeStatistics for - * more details on this field. - */ - 2: optional list unencoded_byte_array_data_bytes -} - -/** - * Optional statistics for each data page in a ColumnChunk. - * - * Forms part the page index, along with OffsetIndex. - * - * If this structure is present, OffsetIndex must also be present. - * - * For each field in this structure, [i] refers to the page at - * OffsetIndex.page_locations[i] - */ -struct ColumnIndex { - /** - * A list of Boolean values to determine the validity of the corresponding - * min and max values. If true, a page contains only null values, and writers - * have to set the corresponding entries in min_values and max_values to - * byte[0], so that all lists have the same length. If false, the - * corresponding entries in min_values and max_values must be valid. - */ - 1: required list null_pages - - /** - * Two lists containing lower and upper bounds for the values of each page - * determined by the ColumnOrder of the column. These may be the actual - * minimum and maximum values found on a page, but can also be (more compact) - * values that do not exist on a page. For example, instead of storing "Blart - * Versenwald III", a writer may set min_values[i]="B", max_values[i]="C". - * Such more compact values must still be valid values within the column's - * logical type. Readers must make sure that list entries are populated before - * using them by inspecting null_pages. - * - * For columns of physical type FLOAT or DOUBLE, or logical type FLOAT16, - * NaN values are not to be included in these bounds. If all non-null values - * of a page are NaN, then a writer must do the following: - * - If the order of this column is TYPE_ORDER, then a column index must - * not be written for this column chunk. While this is unfortunate for - * performance, it is necessary to avoid conflict with legacy files that - * still included NaN in min_values and max_values even if the page had - * non-NaN values. To mitigate this, IEEE754_TOTAL_ORDER is recommended. - * - If the order of this column is IEEE754_TOTAL_ORDER, then min_values[i] - * and max_values[i] of that page must be set to the smallest and largest - * NaN values as defined by IEEE 754 total order. - * - * For columns of physical type INT96, the writer must do the following: - * - If the order of this column is not INT96_TIMESTAMP_ORDER, then a column - * index must not be written for this column chunk. - * - If the order of this column is INT96_TIMESTAMP_ORDER, the min_values[i] - * and max_values[i] of that page must be set to the smallest and largest - * values as defined by the INT96 chronological timestamp ordering. - */ - 2: required list min_values - 3: required list max_values - - /** - * Stores whether both min_values and max_values are ordered and if so, in - * which direction. This allows readers to perform binary searches in both - * lists. Readers cannot assume that max_values[i] <= min_values[i+1], even - * if the lists are ordered. - */ - 4: required BoundaryOrder boundary_order - - /** - * A list containing the number of null values for each page - * - * Writers SHOULD always write this field even if no null values - * are present or the column is not nullable. - * Readers MUST distinguish between null_counts not being present - * and null_count being 0. - * If null_counts are not present, readers MUST NOT assume all - * null counts are 0. - */ - 5: optional list null_counts - - /** - * Contains repetition level histograms for each page - * concatenated together. The repetition_level_histogram field on - * SizeStatistics contains more details. - * - * When present the length should always be (number of pages * - * (max_repetition_level + 1)) elements. - * - * Element 0 is the first element of the histogram for the first page. - * Element (max_repetition_level + 1) is the first element of the histogram - * for the second page. - **/ - 6: optional list repetition_level_histograms; - /** - * Same as repetition_level_histograms except for definitions levels. - **/ - 7: optional list definition_level_histograms; - - /** - * A list containing the number of NaN values for each page. Only present - * for columns of physical type FLOAT or DOUBLE, or logical type FLOAT16. - * If this field is not present, readers MUST assume that there might be - * NaN values in any page. - */ - 8: optional list nan_counts - -} - -struct AesGcmV1 { - /** AAD prefix **/ - 1: optional binary aad_prefix - - /** Unique file identifier part of AAD suffix **/ - 2: optional binary aad_file_unique - - /** In files encrypted with AAD prefix without storing it, - * readers must supply the prefix **/ - 3: optional bool supply_aad_prefix -} - -struct AesGcmCtrV1 { - /** AAD prefix **/ - 1: optional binary aad_prefix - - /** Unique file identifier part of AAD suffix **/ - 2: optional binary aad_file_unique - - /** In files encrypted with AAD prefix without storing it, - * readers must supply the prefix **/ - 3: optional bool supply_aad_prefix -} - -union EncryptionAlgorithm { - 1: AesGcmV1 AES_GCM_V1 - 2: AesGcmCtrV1 AES_GCM_CTR_V1 -} - -/** - * Description for file metadata - */ -struct FileMetaData { - /** Version of this file - * - * As of December 2025, there is no agreed upon consensus of what constitutes - * version 2 of the file. For maximum compatibility with readers, writers should - * always populate "1" for version. For maximum compatibility with writers, - * readers should accept "1" and "2" interchangeably. All other versions are - * reserved for potential future use-cases. - */ - 1: required i32 version - - /** Parquet schema for this file. This schema contains metadata for all the columns. - * The schema is represented as a tree with a single root. The nodes of the tree - * are flattened to a list by doing a depth-first traversal. - * The column metadata contains the path in the schema for that column which can be - * used to map columns to nodes in the schema. - * The first element is the root **/ - 2: required list schema; - - /** Number of rows in this file **/ - 3: required i64 num_rows - - /** Row groups in this file **/ - 4: required list row_groups - - /** Optional key/value metadata **/ - 5: optional list key_value_metadata - - /** String for application that wrote this file. This should be in the format - * version (build ). - * e.g. impala version 1.0 (build 6cf94d29b2b7115df4de2c06e2ab4326d721eb55) - **/ - 6: optional string created_by - - /** - * Sort order used for the min_value and max_value fields in the Statistics - * objects and the min_values and max_values fields in the ColumnIndex - * objects of each column in this file. Sort orders are listed in the order - * matching the columns in the schema. The indexes are not necessarily the same - * though, because only leaf nodes of the schema are represented in the list - * of sort orders. - * - * Without column_orders, the meaning of the min_value and max_value fields - * in the Statistics object and the ColumnIndex object is undefined. To ensure - * well-defined behaviour, if these fields are written to a Parquet file, - * column_orders must be written as well. - * - * The obsolete min and max fields in the Statistics object are always sorted - * by signed comparison regardless of column_orders. - */ - 7: optional list column_orders; - - /** - * Encryption algorithm. This field is set only in encrypted files - * with plaintext footer. Files with encrypted footer store algorithm id - * in FileCryptoMetaData structure. - */ - 8: optional EncryptionAlgorithm encryption_algorithm - - /** - * Retrieval metadata of key used for signing the footer. - * Used only in encrypted files with plaintext footer. - */ - 9: optional binary footer_signing_key_metadata -} - -/** Crypto metadata for files with encrypted footer **/ -struct FileCryptoMetaData { - /** - * Encryption algorithm. This field is only used for files - * with encrypted footer. Files with plaintext footer store algorithm id - * inside footer (FileMetaData structure). - */ - 1: required EncryptionAlgorithm encryption_algorithm - - /** Retrieval metadata of key used for encryption of footer, - * and (possibly) columns **/ - 2: optional binary key_metadata -} From db191086ddb419d5819022d42e82460632b12812 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 21:01:23 +0530 Subject: [PATCH 15/21] made sure test parquet files can be recognized in CI. --- dataframe-parquet/dataframe-parquet.cabal | 1 + 1 file changed, 1 insertion(+) diff --git a/dataframe-parquet/dataframe-parquet.cabal b/dataframe-parquet/dataframe-parquet.cabal index 91657636..8db420b5 100644 --- a/dataframe-parquet/dataframe-parquet.cabal +++ b/dataframe-parquet/dataframe-parquet.cabal @@ -19,6 +19,7 @@ maintainer: mschavinda@gmail.com copyright: (c) 2024-2026 Michael Chavinda category: Data tested-with: GHC ==9.4.8 || ==9.6.7 || ==9.8.4 || ==9.10.3 || ==9.12.2 +extra-source-files: tests/data/*.parquet common warnings ghc-options: From 31b054aede2d4c7167e333bca90f62bc601eb432 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Wed, 19 Aug 2026 21:25:25 +0530 Subject: [PATCH 16/21] Changed dataframe-parquet.cabal after the recommendations from CI's cabal check --- dataframe-parquet/dataframe-parquet.cabal | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dataframe-parquet/dataframe-parquet.cabal b/dataframe-parquet/dataframe-parquet.cabal index 8db420b5..b5f724cd 100644 --- a/dataframe-parquet/dataframe-parquet.cabal +++ b/dataframe-parquet/dataframe-parquet.cabal @@ -100,7 +100,9 @@ executable dataframe-parquet-10gb-stress main-is: StressMain.hs other-modules: DataFrame10GB hs-source-dirs: stress - if !flag(stress-tests) + if flag(stress-tests) + ghc-options: -prof -fprof-auto + else buildable: False build-depends: base >= 4 && < 5, dataframe-core >= 2.4 && < 2.5, @@ -111,7 +113,7 @@ executable dataframe-parquet-10gb-stress time >= 1.12 && < 2, vector >= 0.13 && < 0.15 default-language: Haskell2010 - ghc-options: -O2 -prof -fprof-auto -threaded -rtsopts -with-rtsopts=-N + ghc-options: -O2 -threaded -rtsopts -with-rtsopts=-N benchmark dataframe-parquet-writer-10gb import: warnings From fa0aeeeef46650ef59ab1aec2819a01236c89438 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Thu, 20 Aug 2026 11:52:05 +0530 Subject: [PATCH 17/21] removed ghcoptions from dataframe-parquet-10gb stress so CI wont reject it --- dataframe-parquet/dataframe-parquet.cabal | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/dataframe-parquet/dataframe-parquet.cabal b/dataframe-parquet/dataframe-parquet.cabal index b5f724cd..201e2f78 100644 --- a/dataframe-parquet/dataframe-parquet.cabal +++ b/dataframe-parquet/dataframe-parquet.cabal @@ -100,10 +100,6 @@ executable dataframe-parquet-10gb-stress main-is: StressMain.hs other-modules: DataFrame10GB hs-source-dirs: stress - if flag(stress-tests) - ghc-options: -prof -fprof-auto - else - buildable: False build-depends: base >= 4 && < 5, dataframe-core >= 2.4 && < 2.5, dataframe-parquet, @@ -113,7 +109,7 @@ executable dataframe-parquet-10gb-stress time >= 1.12 && < 2, vector >= 0.13 && < 0.15 default-language: Haskell2010 - ghc-options: -O2 -threaded -rtsopts -with-rtsopts=-N + -- ghc-options: -O2 -threaded -rtsopts -with-rtsopts=-N benchmark dataframe-parquet-writer-10gb import: warnings From 047967909e0f0b4e431b5edad9da9f83a40146d9 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Thu, 20 Aug 2026 12:11:10 +0530 Subject: [PATCH 18/21] Removed the comment at the beginning of Writer.hs --- .../src/DataFrame/IO/Parquet/Writer.hs | 63 ------------------- 1 file changed, 63 deletions(-) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs index 552767f5..2d653d65 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs @@ -51,69 +51,6 @@ import DataFrame.Internal.DataFrame ( import Pinch (enum, putField) import qualified Pinch --- A Parquet file is a series of row groups followed by the file metadata (which contains the schema and the --- metadata for all the rowgroups, which, in turn, contain the metadata for each column chunk). Inside each --- rowgroup is a series of column chunks. Column chunks consist of a series of pages. Pages are the PageHeader --- followed by RLE encoded definition levels (if they exist), RLE encoded repetition levels (if they exist), --- and finally the encoded and then compressed data. I forgot about magic bytes. Those are there too. --- --- For a parquet file to acheive efficient compression we tend to desirc row groupts of a specific size and --- for each of our column chunks to have pages that are of a specific size. So we must expose these fiddle --- factors to the user so they can tune the writer to have the behavior they want. (there are subtleties to --- this that are discussed further below) --- --- We'll set the default Page size to 1 MiB and the default rowGroupSize to 128MiB (of course users will be --- able to adjust these numbers through write options). We need to hold the entire RowGroup in memory as --- we build is as the ColumnChunks need to be contiguous when written to disk. So we need to hold --- buffers for each individual columnChunk as we go row by row and build them; the columnChunks cannot be --- interleaved. - --- Since dataframe is columnar to begin with, we could, in theory, go golumn by column by estimating the size --- of a certain slice of a column, but I don't yet see a good way of doing this given we must run the gamut --- of encodings and compressions applied to each of those ColumnChunks (and those compression libraries have --- their own multifarious strategies for various kinds of data) --- --- Each row group has to be a certain size, but each column in a row group must contain the same number --- of rows, even though each column may very well fit the same number of rows in very different amounts --- of space. So how do we ensure that we both hit our page size target, our record size target, and have --- the same number of rows in each column? --- --- We must consider the page size and row group sizes to be best effort. They could be slightly above --- or below the target. The characteristics of the parquet file will depend on both the write options and --- the specific data being encoded. Arrow-rs runs batches of rows through the writer, flushing when --- they see that a page/rowgroup has met or exceeded its limit. --- --- So a row group is flushed specifically only on batch boundaries and we get the same number of rows in --- every row group except the last which will be smaller than the rest. They also use sub batching. so --- as to not overshoot page size egregiously if the user sets a large batch size. Note: --- arrow-rs had an issue where some columns had really large values. --- See https://github.com/apache/arrow-rs/issues/10061. --- --- We may need to implement batching and sub batching eventually but I'm too lazy to do it right now. --- --- If larger row groups are required (up to a gigabyte in size if not more), we should provide users who --- need to minimize memory usage an alternate two pass strategy where we first write to temporary files (one --- per columnChunk) until the temporary files have grown to the size of what a rowgroup should actually be --- and pipe the temporary files into the output. Essentially our rowgroup buffer is on disk instead of in --- memory. This is slower but should use less memory. In cases where there is extra RAM available but the --- user chooses the two pass strategy anyway, the temp files will tend to be held in the OS Page Cache (RAM) --- anyway. --- --- Niceties like statistics and bloom filters and so on have not yet been implemented. We may need some --- extra machinery to keep track of row ranges so we can use them with the dataframe to generate our --- statistics. --- --- We haven't yet implemented all the encodings and compressions possible. The writer should first --- be brought to parity with the reader, and then we should implement encodings and compressions in --- both together so neither lags behind the other. --- --- We also don't yet support a way to have different compressions/endodings per page, and I imagine --- we would use some kind of heuristid to select these things, if we should want such a thing at all --- --- Repetition levels and Definitions levlels above 1 are also not yet supported, but that may come hand --- in hand with bigger work where we work out the best way to support arbitraritly nested rows in --- dataframe in a general way (as opposed to what we have today) - writeParquet :: FilePath -> DataFrame -> IO () writeParquet = writeParquetWithOptions defaultParquetWriteOptions From c203983fdc9600b758bf6bb493566cb71fa62a59 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Fri, 21 Aug 2026 11:23:21 +0530 Subject: [PATCH 19/21] Fixed a bug where attempting to write a parquet file with a name that already exists appends to the old file instead of overwriting it --- dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs index 6008e9a9..90f9fc83 100644 --- a/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs +++ b/dataframe-parquet/src/DataFrame/IO/Utils/RandomAccess.hs @@ -81,7 +81,7 @@ import GHC.Float (castDoubleToWord64, castFloatToWord32) import System.IO ( BufferMode (NoBuffering), Handle, - IOMode (AppendMode, ReadWriteMode), + IOMode (ReadWriteMode, WriteMode), SeekMode (AbsoluteSeek), hClose, hGetBuf, @@ -170,7 +170,7 @@ newtype WritableBinaryHandle = WritableBinaryHandle {unHandle :: Handle} openWritableBinaryFile :: FilePath -> IO WritableBinaryHandle openWritableBinaryFile filepath = do - h <- openBinaryFile filepath AppendMode + h <- openBinaryFile filepath WriteMode hSetBinaryMode h True hSetBuffering h NoBuffering pure . WritableBinaryHandle $ h From 8e067e1e4a0eede53aadb93222e75a66f0fcc387 Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Fri, 21 Aug 2026 11:45:01 +0530 Subject: [PATCH 20/21] Fixed a bug where the compressed size was being written to RowGroup.total_byte_size when parquet.thrift stipulates that it must be the uncompressed size --- .../src/DataFrame/IO/Parquet/Writer.hs | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs index 2d653d65..38c558cd 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs @@ -99,22 +99,28 @@ finalizeRowGroup :: ParquetWriteOptions -> WriterState -> IO () finalizeRowGroup opts st = do rgRows <- readIORef st.wsRgRows when (rgRows > 0) $ do + -- flush the page buffer of each columnChunk into their respective ColumnChunk Buffer + -- before flushing the entire row group VB.mapM_ (runColumnChunkWriter (finalizePage opts.compressionCodec)) st.wsCols - (chunksRev, total) <- + (chunksRev, totalCompressed, totalUncompressed) <- VB.foldM' - ( \(acc, totalSize) cs -> do + ( \(acc, totalCompressedSize, totalUncompressedSize) cs -> do offset <- readIORef st.wsFileOffset - size <- bufferResidency (ckBuffer cs) - uncompressed <- readIORef (ckUncompressed cs) + compressedSize <- bufferResidency (ckBuffer cs) + uncompressedSize <- readIORef (ckUncompressed cs) flushBufferToFile st.wsOut (ckBuffer cs) - writeIORef st.wsFileOffset (offset + fromIntegral size) + writeIORef st.wsFileOffset (offset + fromIntegral compressedSize) writeIORef (ckUncompressed cs) 0 - let chunk = mkColumnChunk opts offset size uncompressed rgRows cs - pure (chunk : acc, totalSize + fromIntegral size) + let chunk = mkColumnChunk opts offset compressedSize uncompressedSize rgRows cs + pure ( + chunk : acc, + totalCompressedSize + fromIntegral compressedSize, + totalUncompressedSize + fromIntegral uncompressedSize + ) ) - ([], 0 :: Int64) + ([], 0 :: Int64, 0 :: Int64) st.wsCols - modifyIORef' st.wsRowGroups (mkRowGroup (reverse chunksRev) total rgRows :) + modifyIORef' st.wsRowGroups (mkRowGroup (reverse chunksRev) totalCompressed totalUncompressed rgRows :) writeIORef st.wsRgRows 0 mkColumnChunk :: @@ -125,7 +131,7 @@ mkColumnChunk :: Int -> ColumnChunkState -> ColumnChunk -mkColumnChunk opts offset size uncompressed rgRows cs = +mkColumnChunk opts offset compressedSize uncompressedSize rgRows cs = ColumnChunk { cc_file_path = putField Nothing , cc_file_offset = putField offset @@ -145,8 +151,8 @@ mkColumnChunk opts offset size uncompressed rgRows cs = , cmd_path_in_schema = putField [ckName cs] , cmd_codec = putField opts.compressionCodec , cmd_num_values = putField (fromIntegral rgRows) - , cmd_total_uncompressed_size = putField uncompressed - , cmd_total_compressed_size = putField (fromIntegral size) + , cmd_total_uncompressed_size = putField uncompressedSize + , cmd_total_compressed_size = putField (fromIntegral compressedSize) , cmd_key_value_metadata = putField Nothing , cmd_data_page_offset = putField offset , cmd_index_page_offset = putField Nothing @@ -157,15 +163,15 @@ mkColumnChunk opts offset size uncompressed rgRows cs = , cmd_bloom_filter_length = putField Nothing } -mkRowGroup :: [ColumnChunk] -> Int64 -> Int -> RowGroup -mkRowGroup chunks total rgRows = +mkRowGroup :: [ColumnChunk] -> Int64 -> Int64 -> Int -> RowGroup +mkRowGroup chunks totalCompressed totalUncompressed rgRows = RowGroup { rg_columns = putField chunks - , rg_total_byte_size = putField total + , rg_total_byte_size = putField totalUncompressed , rg_num_rows = putField (fromIntegral rgRows) , rg_sorting_columns = putField Nothing , rg_file_offset = putField Nothing - , rg_total_compressed_size = putField (Just total) + , rg_total_compressed_size = putField (Just totalCompressed) , rg_ordinal = putField Nothing } From ab6f785ab9eeda846f66f48c7a35d681e2d5ce4e Mon Sep 17 00:00:00 2001 From: Raghav Sharma Date: Fri, 21 Aug 2026 13:22:10 +0530 Subject: [PATCH 21/21] Removed some dead code; Fixed an issue where defLevels weren't counted towards page size when building a page --- .../src/DataFrame/IO/Parquet/Writer.hs | 14 ++++++++------ .../IO/Parquet/Writer/ColumnChunkWriter.hs | 19 +++++-------------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs index 38c558cd..b4a46709 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer.hs @@ -112,15 +112,17 @@ finalizeRowGroup opts st = do writeIORef st.wsFileOffset (offset + fromIntegral compressedSize) writeIORef (ckUncompressed cs) 0 let chunk = mkColumnChunk opts offset compressedSize uncompressedSize rgRows cs - pure ( - chunk : acc, - totalCompressedSize + fromIntegral compressedSize, - totalUncompressedSize + fromIntegral uncompressedSize - ) + pure + ( chunk : acc + , totalCompressedSize + fromIntegral compressedSize + , totalUncompressedSize + fromIntegral uncompressedSize + ) ) ([], 0 :: Int64, 0 :: Int64) st.wsCols - modifyIORef' st.wsRowGroups (mkRowGroup (reverse chunksRev) totalCompressed totalUncompressed rgRows :) + modifyIORef' + st.wsRowGroups + (mkRowGroup (reverse chunksRev) totalCompressed totalUncompressed rgRows :) writeIORef st.wsRgRows 0 mkColumnChunk :: diff --git a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs index 71469152..b0bc354e 100644 --- a/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs +++ b/dataframe-parquet/src/DataFrame/IO/Parquet/Writer/ColumnChunkWriter.hs @@ -9,9 +9,7 @@ module DataFrame.IO.Parquet.Writer.ColumnChunkWriter ( page, askColumnChunk, initColumnState, - writeRow, writeRowAndMaybeFinalize, - maybeFinalizePage, finalizePage, bufferedSize, ) where @@ -19,7 +17,7 @@ module DataFrame.IO.Parquet.Writer.ColumnChunkWriter ( import Control.Monad (when) import Control.Monad.IO.Class (MonadIO (..)) import qualified Data.ByteString as BS -import Data.IORef (IORef, modifyIORef', newIORef) +import Data.IORef (IORef, modifyIORef', newIORef, readIORef) import Data.Int (Int64) import qualified Data.Text as T import qualified Data.Vector as VB @@ -38,9 +36,8 @@ import DataFrame.IO.Parquet.Writer.PageWriter ( ) import DataFrame.IO.Utils.RandomAccess ( HasBuffer (..), - MemoryBuffer, + MemoryBuffer (..), ReaderIO (runReaderIO), - Sink (..), bufferResidency, bufferToByteString, copyBuffer, @@ -116,9 +113,6 @@ initColumnState opts name col = do , ckPage = pageState } -writeRow :: Int -> ColumnChunkWriter () -writeRow row = ColumnChunkWriter (writeRowIO row) - writeRowIO :: Int -> ColumnChunkState -> IO () writeRowIO row st = do let pageState = ckPage st @@ -131,17 +125,14 @@ writeRowAndMaybeFinalize :: ParquetWriteOptions -> Int -> ColumnChunkState -> IO () writeRowAndMaybeFinalize opts row st = do writeRowIO row st - size <- bufferResidency st.ckPage.psValues + valuesSize <- bufferResidency st.ckPage.psValues + levelsSize <- bufferResidency st.ckPage.psDefs.dlBuf + let size = valuesSize + levelsSize when (size >= opts.pageSize) (runColumnChunkWriter (finalizePage opts.compressionCodec) st) {-# INLINE writeRowAndMaybeFinalize #-} -maybeFinalizePage :: ParquetWriteOptions -> ColumnChunkWriter () -maybeFinalizePage opts = do - size <- page residency - when (size >= opts.pageSize) (finalizePage opts.compressionCodec) - finalizePage :: CompressionCodec -> ColumnChunkWriter () finalizePage codec = do st <- askColumnChunk