packages feed

dataframe-core-1.1.0.0: src/DataFrame/Internal/AggKernelDirect.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ExplicitNamespaces #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

{- | Low-cardinality DIRECT-INDEXED accumulator fast path (research #4).

When the group-by key resolves to a small dense domain (@nGroups@ below
'directThreshold'), the dense @rowToGroup@ vector handed down by the grouping
layer already IS the group id per row, so we can bypass the @valueIndices@
gather entirely: scan @rowToGroup@ and the value column /in lockstep, in
original row order/, scattering each value straight into a per-group accumulator
array indexed by the dense id. Both arrays are read sequentially (no random
gather through @valueIndices@), and for a small domain the accumulator stays
cache-resident.

For parallelism we use the two-phase morsel pattern (#2): split the ROW range
into one contiguous chunk per capability, give each worker a private tiny
accumulator array, scan its chunk sequentially, then merge the @caps@ partials in
a cheap O(caps * nGroups) pass. This makes few-group questions scale (the work
per worker is a tight sequential pass) instead of being dominated by parallel
fan-out, which is exactly the regression the group-range gather suffered on Q4.

CRITICAL — byte-identical at any @-N@: the two-phase merge only changes the
fold/combine order, so it is admitted ONLY for reductions whose result is
independent of that order: integer sum (exact, associative), count, min, max,
and integer mean (an exact integer sum and count divided once at finalize). The
order-sensitive float reductions (Double sum/mean, variance, sd, top2) are NOT
routed here — the caller keeps the order-preserving group-range kernel for them,
so the db-benchmark checksums stay byte-identical between -N1 and -N8.
-}
module DataFrame.Internal.AggKernelDirect (
    directThreshold,
    directReduce,
) where

import Control.Concurrent (forkIO, getNumCapabilities)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Exception (SomeException, throwIO, try)
import Data.Type.Equality (TestEquality (..), type (:~:) (Refl))
import qualified Data.Vector.Unboxed as VU
import qualified Data.Vector.Unboxed.Mutable as VUM
import System.IO.Unsafe (unsafePerformIO)
import Type.Reflection (typeRep)

import DataFrame.Internal.AggKernel (Reduction (..))
import DataFrame.Internal.Column (
    Column (..),
    fromUnboxedVector,
    materializePacked,
 )

{- | Group-domain size at or below which the direct-indexed accumulator path is
taken. The db-benchmark low-cardinality questions (id1=100, id4=100, id6=1e5,
id2:id4=1e4) sit at or below it; wider domains keep the group-range kernel. The
admitted reductions are order-independent, so the per-worker accumulator merge is
exact regardless of size.
-}
directThreshold :: Int
directThreshold = 262144

capabilities :: Int
capabilities = unsafePerformIO getNumCapabilities
{-# NOINLINE capabilities #-}

{- | Below this many rows the parallel fan-out is not worth it; a single
sequential direct pass runs instead (tiny accumulator, one tight loop). Matches
the grouping/scatter parallel threshold.
-}
parThreshold :: Int
parThreshold = 200000

{- | Run a recognised reduction through the direct-indexed path. Returns
'Nothing' (so the caller falls back to the order-preserving kernel) unless BOTH:
(a) the reduction's result is order-independent at this element type — see the
module note — and (b) the value column is a clean unboxed Int/Double column.

@g@ is the dense @rowToGroup@ vector (group id per row, original row order);
@nGroups@ is the dense domain size, already verified @<= directThreshold@ by the
caller.
-}
directReduce :: Reduction -> VU.Vector Int -> Int -> Column -> Maybe Column
directReduce red g nGroups col = case col of
    UnboxedColumn Nothing (v :: VU.Vector a) ->
        case testEquality (typeRep @a) (typeRep @Int) of
            Just Refl -> directInt red g nGroups v
            Nothing -> case testEquality (typeRep @a) (typeRep @Double) of
                Just Refl -> directDouble red g nGroups v
                Nothing -> Nothing
    p@(PackedText _ _) -> directReduce red g nGroups (materializePacked p)
    _ -> Nothing
{-# INLINEABLE directReduce #-}

-- | The order-independent reductions over an Int column.
directInt :: Reduction -> VU.Vector Int -> Int -> VU.Vector Int -> Maybe Column
directInt red g nGroups v = case red of
    RCount -> Just (fromUnboxedVector (countDirect g nGroups (VU.length v)))
    RSum -> Just (fromUnboxedVector (sumIntDirect g nGroups v))
    RMin -> Just (fromUnboxedVector (extremaIntDirect True g nGroups v))
    RMax -> Just (fromUnboxedVector (extremaIntDirect False g nGroups v))
    RMean -> Just (fromUnboxedVector (meanIntDirect g nGroups v))
    _ -> Nothing

{- | Over a Double column only @count@ is order-independent; the float
sum/mean/variance reductions must keep the order-preserving kernel.
-}
directDouble ::
    Reduction -> VU.Vector Int -> Int -> VU.Vector Double -> Maybe Column
directDouble red g nGroups v = case red of
    RCount -> Just (fromUnboxedVector (countDirect g nGroups (VU.length v)))
    _ -> Nothing

-- | Whether to fan out at this row count.
shouldPar :: Int -> Bool
shouldPar n = n >= parThreshold && capabilities > 1

{- | Fork @caps@ workers over disjoint contiguous ROW ranges @[lo, hi)@ of
@[0, n)@, balanced evenly by row count; each worker @w@ runs @fill lo hi@
producing its OWN private accumulator (thread-local, no shared array, no sync).
Returns the partials in worker order for the caller's merge. Rethrows the first
worker failure.
-}
runPartialsOver ::
    Int -> Int -> (Int -> Int -> IO (VUM.IOVector Int)) -> IO [VUM.IOVector Int]
runPartialsOver n caps fill = do
    let !per = (n + caps - 1) `div` caps
        spawn w = do
            var <- newEmptyMVar
            let !lo = min n (w * per)
                !hi = min n (lo + per)
            _ <- forkIO (try (fill lo hi) >>= putMVar var)
            pure var
    vars <- mapM spawn [0 .. caps - 1]
    results <- mapM takeMVar vars
    mapM (either (throwIO @SomeException) pure) results

{- | As 'runPartialsOver' but each worker produces a PAIR of accumulators (e.g.
sum and count for the fused integer mean).
-}
runPartialsPairOver ::
    Int ->
    Int ->
    (Int -> Int -> IO (VUM.IOVector Int, VUM.IOVector Int)) ->
    IO [(VUM.IOVector Int, VUM.IOVector Int)]
runPartialsPairOver n caps fill = do
    let !per = (n + caps - 1) `div` caps
        spawn w = do
            var <- newEmptyMVar
            let !lo = min n (w * per)
                !hi = min n (lo + per)
            _ <- forkIO (try (fill lo hi) >>= putMVar var)
            pure var
    vars <- mapM spawn [0 .. caps - 1]
    results <- mapM takeMVar vars
    mapM (either (throwIO @SomeException) pure) results

-------------------------------------------------------------------------------
-- Count (order-independent: per-group row count)
-------------------------------------------------------------------------------

countDirect :: VU.Vector Int -> Int -> Int -> VU.Vector Int
countDirect g nGroups n
    | not (shouldPar n) =
        unsafePerformIO (countChunk g nGroups 0 n >>= VU.unsafeFreeze)
    | otherwise = unsafePerformIO $ do
        parts <- runPartialsOver n capabilities (countChunk g nGroups)
        mergeIntSum nGroups parts
{-# NOINLINE countDirect #-}

countChunk :: VU.Vector Int -> Int -> Int -> Int -> IO (VUM.IOVector Int)
countChunk g nGroups lo hi = do
    acc <- VUM.replicate nGroups (0 :: Int)
    let go !i
            | i >= hi = pure ()
            | otherwise = do
                let !k = VU.unsafeIndex g i
                c <- VUM.unsafeRead acc k
                VUM.unsafeWrite acc k (c + 1)
                go (i + 1)
    go lo
    pure acc

-------------------------------------------------------------------------------
-- Integer sum (exact: merge order irrelevant)
-------------------------------------------------------------------------------

sumIntDirect :: VU.Vector Int -> Int -> VU.Vector Int -> VU.Vector Int
sumIntDirect g nGroups v
    | not (shouldPar n) =
        unsafePerformIO (sumIntChunk g v nGroups 0 n >>= VU.unsafeFreeze)
    | otherwise = unsafePerformIO $ do
        parts <- runPartialsOver n capabilities (sumIntChunk g v nGroups)
        mergeIntSum nGroups parts
  where
    !n = VU.length v
{-# NOINLINE sumIntDirect #-}

sumIntChunk ::
    VU.Vector Int -> VU.Vector Int -> Int -> Int -> Int -> IO (VUM.IOVector Int)
sumIntChunk g v nGroups lo hi = do
    acc <- VUM.replicate nGroups (0 :: Int)
    let go !i
            | i >= hi = pure ()
            | otherwise = do
                let !k = VU.unsafeIndex g i
                c <- VUM.unsafeRead acc k
                VUM.unsafeWrite acc k (c + VU.unsafeIndex v i)
                go (i + 1)
    go lo
    pure acc

-------------------------------------------------------------------------------
-- Integer min / max (order-independent)
-------------------------------------------------------------------------------

extremaIntDirect ::
    Bool -> VU.Vector Int -> Int -> VU.Vector Int -> VU.Vector Int
extremaIntDirect isMin g nGroups v
    | not (shouldPar n) =
        unsafePerformIO (extremaIntChunk isMin g v nGroups 0 n >>= VU.unsafeFreeze)
    | otherwise = unsafePerformIO $ do
        parts <- runPartialsOver n capabilities (extremaIntChunk isMin g v nGroups)
        mergeExtremaInt isMin nGroups parts
  where
    !n = VU.length v
{-# NOINLINE extremaIntDirect #-}

extremaIntChunk ::
    Bool ->
    VU.Vector Int ->
    VU.Vector Int ->
    Int ->
    Int ->
    Int ->
    IO (VUM.IOVector Int)
extremaIntChunk isMin g v nGroups lo hi = do
    let !seed = if isMin then maxBound else minBound
        combine a b = if isMin then min a b else max a b
    acc <- VUM.replicate nGroups seed
    let go !i
            | i >= hi = pure ()
            | otherwise = do
                let !k = VU.unsafeIndex g i
                c <- VUM.unsafeRead acc k
                VUM.unsafeWrite acc k (combine c (VU.unsafeIndex v i))
                go (i + 1)
    go lo
    pure acc

-------------------------------------------------------------------------------
-- Integer mean (exact integer sum + count, divided once -> order-independent)
-------------------------------------------------------------------------------

{- | Integer mean in ONE fused pass: a running integer sum and count per group,
divided once at finalize. The integer sum is exact, so the parallel partial
merge is byte-identical to the sequential single pass at any @-N@.
-}
meanIntDirect :: VU.Vector Int -> Int -> VU.Vector Int -> VU.Vector Double
meanIntDirect g nGroups v
    | not (shouldPar n) = unsafePerformIO $ do
        (s, c) <- meanIntChunk g v nGroups 0 n
        finalizeMeanInt nGroups s c
    | otherwise = unsafePerformIO $ do
        parts <- runPartialsPairOver n capabilities (meanIntChunk g v nGroups)
        (s, c) <- mergePair nGroups parts
        finalizeMeanInt nGroups s c
  where
    !n = VU.length v
{-# NOINLINE meanIntDirect #-}

meanIntChunk ::
    VU.Vector Int ->
    VU.Vector Int ->
    Int ->
    Int ->
    Int ->
    IO (VUM.IOVector Int, VUM.IOVector Int)
meanIntChunk g v nGroups lo hi = do
    s <- VUM.replicate nGroups (0 :: Int)
    c <- VUM.replicate nGroups (0 :: Int)
    let go !i
            | i >= hi = pure ()
            | otherwise = do
                let !k = VU.unsafeIndex g i
                sv <- VUM.unsafeRead s k
                VUM.unsafeWrite s k (sv + VU.unsafeIndex v i)
                cv <- VUM.unsafeRead c k
                VUM.unsafeWrite c k (cv + 1)
                go (i + 1)
    go lo
    pure (s, c)

finalizeMeanInt ::
    Int -> VUM.IOVector Int -> VUM.IOVector Int -> IO (VU.Vector Double)
finalizeMeanInt nGroups s c = do
    out <- VUM.new nGroups
    let go !k
            | k >= nGroups = pure ()
            | otherwise = do
                sv <- VUM.unsafeRead s k
                cv <- VUM.unsafeRead c k
                VUM.unsafeWrite
                    out
                    k
                    (if cv == 0 then 0 / 0 else fromIntegral sv / fromIntegral cv)
                go (k + 1)
    go 0
    VU.unsafeFreeze out

-------------------------------------------------------------------------------
-- Partial accumulation + merge
-------------------------------------------------------------------------------

mergeIntSum :: Int -> [VUM.IOVector Int] -> IO (VU.Vector Int)
mergeIntSum nGroups parts = case parts of
    [] -> VU.unsafeFreeze =<< VUM.replicate nGroups 0
    (p0 : rest) -> do
        let add !p = do
                let go !k
                        | k >= nGroups = pure ()
                        | otherwise = do
                            a <- VUM.unsafeRead p0 k
                            b <- VUM.unsafeRead p k
                            VUM.unsafeWrite p0 k (a + b)
                            go (k + 1)
                go 0
        mapM_ add rest
        VU.unsafeFreeze p0

{- | Merge per-worker (sum, count) partials into the first worker's pair by
exact integer addition; returns the accumulated pair for finalize.
-}
mergePair ::
    Int ->
    [(VUM.IOVector Int, VUM.IOVector Int)] ->
    IO (VUM.IOVector Int, VUM.IOVector Int)
mergePair nGroups parts = case parts of
    [] -> (,) <$> VUM.replicate nGroups 0 <*> VUM.replicate nGroups 0
    ((s0, c0) : rest) -> do
        let add (s, c) = do
                let go !k
                        | k >= nGroups = pure ()
                        | otherwise = do
                            sa <- VUM.unsafeRead s0 k
                            sb <- VUM.unsafeRead s k
                            VUM.unsafeWrite s0 k (sa + sb)
                            ca <- VUM.unsafeRead c0 k
                            cb <- VUM.unsafeRead c k
                            VUM.unsafeWrite c0 k (ca + cb)
                            go (k + 1)
                go 0
        mapM_ add rest
        pure (s0, c0)

mergeExtremaInt :: Bool -> Int -> [VUM.IOVector Int] -> IO (VU.Vector Int)
mergeExtremaInt isMin nGroups parts = case parts of
    [] ->
        VU.unsafeFreeze =<< VUM.replicate nGroups (if isMin then maxBound else minBound)
    (p0 : rest) -> do
        let combine a b = if isMin then min a b else max a b
            add !p = do
                let go !k
                        | k >= nGroups = pure ()
                        | otherwise = do
                            a <- VUM.unsafeRead p0 k
                            b <- VUM.unsafeRead p k
                            VUM.unsafeWrite p0 k (combine a b)
                            go (k + 1)
                go 0
        mapM_ add rest
        VU.unsafeFreeze p0