packages feed

dataframe-operations-2.5.0.0: src/DataFrame/Operations/Aggregation.hs

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

module DataFrame.Operations.Aggregation (
    module DataFrame.Operations.Aggregation,
    groupBy,
    buildRowToGroup,
    changingPoints,
) where

import qualified Data.List as L
import qualified Data.Map.Strict as MS
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Data.Vector.Unboxed as VU

import Control.Exception (throw)
import DataFrame.Errors
import DataFrame.Internal.Aggregation.Kernel.Fused (
    mkFusedAgg,
    mkGatherAgg,
    runFusedAggs,
    runGatherAggs,
 )
import DataFrame.Internal.Aggregation.Kernel.Scatter (streamGroupCap)
import DataFrame.Internal.Aggregation.Plan (
    AggPlan (..),
    MomentPlan,
    planAgg,
    planMoments,
 )
import DataFrame.Internal.Aggregation.Reduction (Reduction (..))
import DataFrame.Internal.Column (
    Column (..),
    TypedColumn (..),
    atIndicesStable,
    atIndicesStableMulti,
 )
import DataFrame.Internal.DataFrame (
    DataFrame (..),
    GroupedDataFrame (..),
    columnNames,
    getColumn,
    insertColumn,
 )
import DataFrame.Internal.Expression
import DataFrame.Internal.Grouping (buildRowToGroup, changingPoints, groupBy)
import DataFrame.Internal.Interpreter
import DataFrame.Internal.Row.RowHash (computeRowHashesIO)
import DataFrame.Operations.Aggregation.Run (
    runMedianVarFused,
    runMomentPlan,
    runPlan,
 )
import DataFrame.Operations.Core
import DataFrame.Operations.Subset
import System.IO.Unsafe (unsafePerformIO)

{- | Per-row key hash over the selected key columns. Delegates to the shared
'computeRowHashesIO' kernel, which forks over contiguous row ranges for large
frames (the hashing of a wide 1e7-row text/factor join key dominates that join)
and is bit-for-bit identical to a single sequential pass at any capability count.
-}
computeRowHashes :: [Int] -> DataFrame -> VU.Vector Int
computeRowHashes indices df =
    let n = fst (dimensions df)
        selectedCols = map (columns df V.!) indices
     in unsafePerformIO (computeRowHashesIO n selectedCols)
{-# NOINLINE computeRowHashes #-}

{- | Aggregate a grouped dataframe using the expressions given.
All ungrouped columns will be dropped.

NOTE: this function deliberately never pattern-matches or strictly binds the
'Grouped' per-row fields (this module is compiled with @-XStrict@, whose strict
patterns and bindings would force them): on direct-grouped frames BOTH
'valueIndices' (the placement permutation) and 'rowToGroup' are deferred
thunks, and each aggregation path needs at most one of them — always passed as
un-forced argument expressions. Key columns materialize through 'groupRepRows'
(one representative row per group) instead of gathering
@valueIndices[offsets[g]]@.
-}
aggregate :: [NamedExpr] -> GroupedDataFrame -> DataFrame
aggregate aggs gdf =
    let
        df = fullDataframe gdf
        offs = offsets gdf

        {- Key columns materialize through ONE fused parallel gather over the
        representative rows ('atIndicesStableMulti'): the 1e8-group Q10 result
        was six sequential latency-bound random-gather passes as per-column
        'selectIndices'. Each result column is still identical to (and as
        deferred as) the per-column gather; the row-count field uses the eager
        @offsets@ so nothing here forces 'groupRepRows' early. -}
        df' =
            let sub = select (groupedColumns gdf) df
             in sub
                    { columns =
                        V.fromList
                            ( atIndicesStableMulti
                                (groupRepRows gdf)
                                (V.toList (columns sub))
                            )
                    , dataframeDimensions = (nGroups, snd (dataframeDimensions sub))
                    }

        !nGroups = VU.length offs - 1
        !nRows' = fst (dataframeDimensions df)

        {- Fused multi-reduction fast path (Q3/Q4/Q5-shaped aggregates): every
        recognised simple scatter reduction (sum/mean/count/min/max over a clean
        unboxed Int/Double column) in this aggregate runs in ONE pass instead of
        one full pass per expression. At or below 'streamGroupCap' groups that
        pass streams over (rowToGroup, columns) with per-worker accumulators —
        never touching the (possibly lazy) valueIndices; above it (necessarily a
        hash-path grouping, whose valueIndices is already eager) the accumulator
        arrays would thrash, so the pass gathers by disjoint group range instead
        (register accumulators, bit-identical to the unfused gather kernels,
        one traversal instead of one per expression). Only taken when at
        least two reductions fuse; non-fusable expressions (median, var/std,
        max-min, arbitrary DSL) keep their per-expression path below. -}
        {- This binding is strict (-XStrict), so it must stay empty-and-cheap
        whenever the moment path below already covers the aggregate — otherwise
        the pass would run redundantly before the moment result is consulted. -}
        fusedScatterCols :: MS.Map T.Text Column
        fusedScatterCols = case fusedMoments of
            Just _ -> MS.empty
            Nothing
                | nGroups <= streamGroupCap ->
                    let cands =
                            [ (name, fa)
                            | (name, ue) <- aggs
                            , Just (PlanScatter red cname) <- [planAgg gdf ue]
                            , Just c <- [getColumn cname df]
                            , Just fa <- [mkFusedAgg nGroups (rowToGroup gdf) red c]
                            ]
                     in if length cands >= 2
                            then
                                MS.fromList
                                    (zip (map fst cands) (runFusedAggs nRows' nGroups (map snd cands)))
                            else MS.empty
                | otherwise ->
                    let cands =
                            [ (name, ga)
                            | (name, ue) <- aggs
                            , Just (PlanScatter red cname) <- [planAgg gdf ue]
                            , Just c <- [getColumn cname df]
                            , Just ga <-
                                [mkGatherAgg nGroups (valueIndices gdf) offs red c]
                            ]
                     in {- Unlike the stream branch, a SINGLE candidate also
                        takes this path: the per-expression fallback is a
                        scatter over rowToGroup, which on a hash-path grouping
                        is now a deferred thunk — the gather kernel (documented
                        bit-identical to the unfused kernels) works off the
                        already-eager valueIndices instead and skips that whole
                        random-write pass. -}
                        if not (null cands)
                            then
                                MS.fromList
                                    ( zip
                                        (map fst cands)
                                        ( runGatherAggs
                                            (valueIndices gdf)
                                            offs
                                            nGroups
                                            (map snd cands)
                                        )
                                    )
                            else MS.empty

        {- Fused median + std/var over one column (the Q6 shape): both are
        holistic gathers over the same values, so one shared gather serves the
        Welford fold and the median selection ('runMedianVarFused',
        bit-identical to the separate kernels). Only built when a median and a
        std/var on the same column appear together; empty-and-cheap otherwise
        (same strictness caveat as 'fusedScatterCols'). -}
        medianVarCols :: MS.Map T.Text Column
        medianVarCols = case fusedMoments of
            Just _ -> MS.empty
            Nothing ->
                let plans = [(name, plan) | (name, ue) <- aggs, Just plan <- [planAgg gdf ue]]
                    medCols = L.nub [c | (_, PlanMedian c) <- plans]
                 in MS.fromList
                        [ kv
                        | cname <- medCols
                        , let stds = [nm | (nm, PlanScatter RStd c) <- plans, c == cname]
                        , let vars = [nm | (nm, PlanScatter RVar c) <- plans, c == cname]
                        , not (null stds && null vars)
                        , Just c <- [getColumn cname df]
                        , Just (medC, varC, stdC) <- [runMedianVarFused gdf nGroups c]
                        , kv <-
                            [(nm, medC) | (nm, PlanMedian c') <- plans, c' == cname]
                                ++ [(nm, stdC) | nm <- stds]
                                ++ [(nm, varC) | nm <- vars]
                        ]

        -- Fast path: a recognised reduction scatters in one unboxed pass.
        -- Anything 'planAgg' rejects keeps the existing interpreter, so the
        -- general typed + DSL aggregate API stays correct for arbitrary
        -- expressions.
        f ne@(name, uexpr) d =
            let value = case MS.lookup name medianVarCols of
                    Just c -> c
                    Nothing -> case MS.lookup name fusedScatterCols of
                        Just c -> c
                        Nothing -> case planAgg gdf uexpr of
                            Just plan -> runPlan gdf (rowToGroup gdf) nGroups plan
                            Nothing -> interpretNamed gdf ne
             in insertColumn name value d

        -- Fused fast path: the Q9 regression family (count + five moment sums
        -- of two base columns) becomes one scatter over the base columns,
        -- dropping the derived product columns and the six separate folds.
        -- 'planMoments' returns 'Nothing' on any other set, falling back below.
        fusedMoments = do
            mp <- planMoments gdf aggs :: Maybe MomentPlan
            runMomentPlan gdf nGroups mp
     in
        case fusedMoments of
            Just cols -> fold (uncurry insertColumn) cols df'
            Nothing -> fold f aggs df'

-- | The fall-back path: evaluate one named aggregation via the interpreter.
interpretNamed :: GroupedDataFrame -> NamedExpr -> Column
interpretNamed gdf (_, UExpr (expr :: Expr a)) =
    case interpretAggregation @a gdf expr of
        Left e -> throw e
        Right (UnAggregated _) -> throw $ UnaggregatedException (T.pack $ show expr)
        Right (Aggregated (TColumn col)) -> col

selectIndices :: VU.Vector Int -> DataFrame -> DataFrame
selectIndices xs df =
    df
        { columns = V.map (atIndicesStable xs) (columns df)
        , dataframeDimensions = (VU.length xs, V.length (columns df))
        }

-- | Filter out all non-unique values in a dataframe.
distinct :: DataFrame -> DataFrame
distinct df = selectIndices (VU.map (indices VU.!) (VU.init os)) df
  where
    -- The trailing field stays a wildcard: under -XStrict a named pattern
    -- variable would force the (possibly deferred) rowToGroup thunk.
    (Grouped _ _ indices os _) = groupBy (columnNames df) df