packages feed

dataframe-core-2.5.0.0: src-internal/DataFrame/Internal/Column/Builder.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE UndecidableInstances #-}

{- | Mutable, growable column builders for high-throughput ingest. No
per-append @IORef@ traffic: hot counters live in an unboxed vector, payloads
double on demand, and validity is only materialized once a null is seen.
-}
module DataFrame.Internal.Column.Builder (
    ColumnBuilder (..),
    NumBuilder,
    IntBuilder,
    DoubleBuilder,
    TextBuilder,
    TextChunk (..),
    newIntBuilder,
    newDoubleBuilder,
    newNumBuilder,
    newTextBuilder,
    appendInt,
    appendDouble,
    appendNum,
    appendText,
    appendTextSlice,
    appendTextSliceFromPtr,
    freezeTextChunk,
    concatColumns,
    mergeTextChunks,
) where

import qualified Data.Text as T
import qualified Data.Text.Array as A
import qualified Data.Vector.Unboxed as VU
import qualified Data.Vector.Unboxed.Mutable as VUM

import Control.Monad (when)
import Control.Monad.ST (ST)
import Data.Bits (shiftR)
import Data.STRef
import Data.Text.Internal (Text (..))
import Data.Word (Word8)
import DataFrame.Internal.Column (
    Column (UnboxedColumn),
    Columnable,
 )
import DataFrame.Internal.Column.Bitmap (packValidity)
import DataFrame.Internal.Column.Merge (
    TextChunk (..),
    concatColumns,
    mergeTextChunks,
 )
import Foreign.Ptr (Ptr)

{- | Operations shared by all column builders.

NB: Do not use after freezing
-}
class ColumnBuilder b where
    -- | Append a null row (sentinel payload + invalid bit).
    appendNull :: b s -> ST s ()

    -- | Rows appended so far.
    builderLength :: b s -> ST s Int

    -- | Freeze into a fully-forced 'Column'.
    freezeBuilder :: b s -> ST s Column

-- Counter slots shared by the builders: rows, any-null flag, text bytes used.
cRows, cAnyNull, cBytes :: Int
cRows = 0
cAnyNull = 1
cBytes = 2

{- | Builder for unboxed numeric payloads ('Int', 'Double', ...). 'nbNull'
is the sentinel written into null slots.
-}
data NumBuilder a s = NumBuilder
    { nbNull :: !a
    , nbCounters :: !(VUM.MVector s Int)
    , nbArrays :: !(STRef s (NumArrays a s))
    }

data NumArrays a s = NumArrays
    { naData :: !(VUM.MVector s a)
    , naValid :: !(VUM.MVector s Word8)
    }

type IntBuilder = NumBuilder Int

type DoubleBuilder = NumBuilder Double

-- | New numeric builder with a row-capacity hint and a null sentinel.
{-# SPECIALIZE newNumBuilder :: Int -> Int -> ST s (NumBuilder Int s) #-}
{-# SPECIALIZE newNumBuilder :: Double -> Int -> ST s (NumBuilder Double s) #-}
newNumBuilder :: (VU.Unbox a) => a -> Int -> ST s (NumBuilder a s)
newNumBuilder nullValue hint = do
    let cap = max 16 hint
    counters <- VUM.replicate 2 0
    dat <- VUM.unsafeNew cap
    val <- VUM.unsafeNew cap
    NumBuilder nullValue counters <$> newSTRef (NumArrays dat val)

newIntBuilder :: Int -> ST s (IntBuilder s)
newIntBuilder = newNumBuilder 0

newDoubleBuilder :: Int -> ST s (DoubleBuilder s)
newDoubleBuilder = newNumBuilder 0

appendNum :: (VU.Unbox a) => NumBuilder a s -> a -> ST s ()
appendNum b !x = do
    n <- VUM.unsafeRead (nbCounters b) cRows
    anyNull <- VUM.unsafeRead (nbCounters b) cAnyNull
    NumArrays dat val <- reserveNum b n
    VUM.unsafeWrite dat n x
    when (anyNull /= 0) $ VUM.unsafeWrite val n 1
    VUM.unsafeWrite (nbCounters b) cRows (n + 1)
{-# INLINE appendNum #-}

appendInt :: IntBuilder s -> Int -> ST s ()
appendInt = appendNum
{-# INLINE appendInt #-}

appendDouble :: DoubleBuilder s -> Double -> ST s ()
appendDouble = appendNum
{-# INLINE appendDouble #-}

-- Fetch the arrays, growing (doubling) first if row @n@ would not fit.
reserveNum :: (VU.Unbox a) => NumBuilder a s -> Int -> ST s (NumArrays a s)
reserveNum b n = do
    arrs <- readSTRef (nbArrays b)
    if n < VUM.length (naData arrs) then pure arrs else growNum b arrs
{-# INLINE reserveNum #-}

growNum ::
    (VU.Unbox a) => NumBuilder a s -> NumArrays a s -> ST s (NumArrays a s)
growNum b (NumArrays dat val) = do
    let cap = VUM.length dat
    dat' <- VUM.unsafeGrow dat cap
    val' <- VUM.unsafeGrow val cap
    let arrs = NumArrays dat' val'
    writeSTRef (nbArrays b) arrs
    pure arrs

instance (Columnable a, VU.Unbox a) => ColumnBuilder (NumBuilder a) where
    appendNull b = do
        n <- VUM.unsafeRead (nbCounters b) cRows
        anyNull <- VUM.unsafeRead (nbCounters b) cAnyNull
        NumArrays dat val <- reserveNum b n
        VUM.unsafeWrite dat n (nbNull b)
        when (anyNull == 0) $ do
            VUM.set (VUM.slice 0 n val) 1
            VUM.unsafeWrite (nbCounters b) cAnyNull 1
        VUM.unsafeWrite val n 0
        VUM.unsafeWrite (nbCounters b) cRows (n + 1)
    {-# INLINE appendNull #-}

    builderLength b = VUM.unsafeRead (nbCounters b) cRows

    freezeBuilder b = do
        n <- VUM.unsafeRead (nbCounters b) cRows
        anyNull <- VUM.unsafeRead (nbCounters b) cAnyNull
        NumArrays dat val <- readSTRef (nbArrays b)
        !vs <- freezeTrimmed n dat
        if anyNull /= 0
            then do
                !bm <- packValidity n val
                pure $! UnboxedColumn (Just bm) vs
            else pure $! UnboxedColumn Nothing vs

-- Zero-copy freeze; copies to exact size when slack exceeds a quarter of n.
freezeTrimmed :: (VU.Unbox a) => Int -> VUM.MVector s a -> ST s (VU.Vector a)
freezeTrimmed n mv
    | VUM.length mv - n <= n `shiftR` 2 = VU.unsafeFreeze (VUM.slice 0 n mv)
    | otherwise = VU.freeze (VUM.slice 0 n mv)

{- | Builder for 'Text' columns.

Representation is packed. I.e all field bytes go into one exponentially
grown byte array with rows recorded as offsets.
-}
data TextBuilder s = TextBuilder
    { tbCounters :: !(VUM.MVector s Int)
    , tbArrays :: !(STRef s (TextArrays s))
    }

data TextArrays s = TextArrays
    { taBytes :: !(A.MArray s)
    , taByteCap :: !Int
    , taOffsets :: !(VUM.MVector s Int)
    -- ^ Row @i@ spans bytes @[offsets!i, offsets!(i+1))@.
    , taValid :: !(VUM.MVector s Word8)
    }

-- | New text builder with row-count and total-byte capacity hints.
newTextBuilder :: Int -> Int -> ST s (TextBuilder s)
newTextBuilder rowHint byteHint = do
    let rcap = max 16 rowHint
        bcap = max 64 byteHint
    counters <- VUM.replicate 3 0
    bytes <- A.new bcap
    offsets <- VUM.unsafeNew (rcap + 1)
    VUM.unsafeWrite offsets 0 0
    val <- VUM.unsafeNew rcap
    TextBuilder counters <$> newSTRef (TextArrays bytes bcap offsets val)

-- | Append @len@ raw bytes at @off@ in @src@ as one field (one memcpy).
appendTextSlice :: TextBuilder s -> A.Array -> Int -> Int -> ST s ()
appendTextSlice b src off len = do
    (n, pos, arrs) <- reserveText b len
    A.copyI len (taBytes arrs) pos src off
    finishTextAppend b arrs n (pos + len)
{-# INLINE appendTextSlice #-}

-- | 'appendTextSlice' from foreign memory (e.g. an mmapped file buffer).
appendTextSliceFromPtr :: TextBuilder s -> Ptr Word8 -> Int -> ST s ()
appendTextSliceFromPtr b ptr len = do
    (n, pos, arrs) <- reserveText b len
    A.copyFromPointer (taBytes arrs) pos ptr len
    finishTextAppend b arrs n (pos + len)
{-# INLINE appendTextSliceFromPtr #-}

-- | Append an already-decoded 'Text' (its bytes are UTF-8 already).
appendText :: TextBuilder s -> T.Text -> ST s ()
appendText b (Text src off len) = appendTextSlice b src off len
{-# INLINE appendText #-}

finishTextAppend :: TextBuilder s -> TextArrays s -> Int -> Int -> ST s ()
finishTextAppend b arrs n endPos = do
    anyNull <- VUM.unsafeRead (tbCounters b) cAnyNull
    when (anyNull /= 0) $ VUM.unsafeWrite (taValid arrs) n 1
    VUM.unsafeWrite (taOffsets arrs) (n + 1) endPos
    VUM.unsafeWrite (tbCounters b) cRows (n + 1)
    VUM.unsafeWrite (tbCounters b) cBytes endPos
{-# INLINE finishTextAppend #-}

reserveText :: TextBuilder s -> Int -> ST s (Int, Int, TextArrays s)
reserveText b extra = do
    n <- VUM.unsafeRead (tbCounters b) cRows
    pos <- VUM.unsafeRead (tbCounters b) cBytes
    arrs <- readSTRef (tbArrays b)
    arrs' <-
        if n < VUM.length (taValid arrs) && pos + extra <= taByteCap arrs
            then pure arrs
            else growText b arrs (n + 1) (pos + extra)
    pure (n, pos, arrs')
{-# INLINE reserveText #-}

growText :: TextBuilder s -> TextArrays s -> Int -> Int -> ST s (TextArrays s)
growText b (TextArrays bytes bcap offsets val) needRows needBytes = do
    let rcap = VUM.length val
    (offsets', val') <-
        if needRows > rcap
            then do
                let rcap' = max (2 * rcap) needRows
                o <- VUM.unsafeGrow offsets (rcap' - rcap)
                v <- VUM.unsafeGrow val (rcap' - rcap)
                pure (o, v)
            else pure (offsets, val)
    (bytes', bcap') <-
        if needBytes > bcap
            then do
                let cap' = max (2 * bcap) needBytes
                bs <- A.resizeM bytes cap'
                pure (bs, cap')
            else pure (bytes, bcap)
    let arrs = TextArrays bytes' bcap' offsets' val'
    writeSTRef (tbArrays b) arrs
    pure arrs

{- | Freeze a 'TextBuilder' into a raw 'TextChunk' for later merging
('mergeTextChunks').
-}
freezeTextChunk :: TextBuilder s -> ST s TextChunk
freezeTextChunk b = do
    n <- VUM.unsafeRead (tbCounters b) cRows
    anyNull <- VUM.unsafeRead (tbCounters b) cAnyNull
    used <- VUM.unsafeRead (tbCounters b) cBytes
    TextArrays bytes bcap offsets val <- readSTRef (tbArrays b)
    when (used < bcap) (A.shrinkM bytes used)
    arr <- A.unsafeFreeze bytes
    offs <- VU.unsafeFreeze (VUM.slice 0 (n + 1) offsets)
    bm <-
        if anyNull /= 0
            then Just <$> packValidity n val
            else pure Nothing
    pure (TextChunk arr used offs bm)

instance ColumnBuilder TextBuilder where
    appendNull b = do
        (n, pos, arrs) <- reserveText b 0
        anyNull <- VUM.unsafeRead (tbCounters b) cAnyNull
        when (anyNull == 0) $ do
            VUM.set (VUM.slice 0 n (taValid arrs)) 1
            VUM.unsafeWrite (tbCounters b) cAnyNull 1
        VUM.unsafeWrite (taValid arrs) n 0
        VUM.unsafeWrite (taOffsets arrs) (n + 1) pos
        VUM.unsafeWrite (tbCounters b) cRows (n + 1)
        VUM.unsafeWrite (tbCounters b) cBytes pos
    {-# INLINE appendNull #-}

    builderLength b = VUM.unsafeRead (tbCounters b) cRows

    freezeBuilder b = do
        chunk <- freezeTextChunk b
        pure $! mergeTextChunks [chunk]