packages feed

dataframe-core-2.6.0.0: src-internal/DataFrame/Internal/Grouping/Direct.hs

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

-- | Low-cardinality direct-indexed grouping fast path for dense Int key codes.
module DataFrame.Internal.Grouping.Direct (
    directGroupThreshold,
    tryDirectGroupColumn,
    groupCodesMaybe,
    directLayoutLazy,
    visFromRowToGroup,
    ascendingCodeGroups,
    rangeOf,
    useTwoLevel,
    DirectGrouping (..),
) where

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 Control.Monad (when)
import Control.Monad.ST (runST)
import Data.Bits (countLeadingZeros, unsafeShiftL, unsafeShiftR, (.&.))
import DataFrame.Internal.Column (Column (..))
import DataFrame.Internal.Control.Concurrent (
    capabilities,
    chunksFor,
    forkJoin,
    parThreshold,
    parallelChunks,
    pooledIndices,
    shouldParallelize,
    splitChunkRange,
 )

-- | Largest key code domain the direct grouping path accepts.
directGroupThreshold :: Int
directGroupThreshold = 1048576

-- | The grouping layout the hash path also produces.
data DirectGrouping = DirectGrouping
    { dgRowToGroup :: !(VU.Vector Int)
    , dgValueIndices :: !(VU.Vector Int)
    , dgOffsets :: !(VU.Vector Int)
    , dgNGroups :: !Int
    }

-- | Take the direct path for a clean small-range Int key, else 'Nothing'.
tryDirectGroupColumn :: Column -> Maybe DirectGrouping
tryDirectGroupColumn (UnboxedColumn Nothing (v :: VU.Vector a))
    | Just Refl <- testEquality (typeRep @a) (typeRep @Int)
    , not (VU.null v) =
        let (!mn, !mx) = rangeOf v
            !range = mx - mn + 1
         in if range >= 1 && range <= directGroupThreshold
                then
                    groupCodesMaybe
                        (\i -> VU.unsafeIndex v i - mn)
                        (VU.length v)
                        range
                        ascendingCodeGroups
                else Nothing
tryDirectGroupColumn _ = Nothing

-- | Parallel min/max reduce (order-independent).
rangeOf :: VU.Vector Int -> (Int, Int)
rangeOf v
    | not (shouldParallelize parThreshold n) = rangeChunk v 0 n
    | otherwise = unsafePerformIO $ do
        rs <- parallelChunks parThreshold n (\lo hi -> pure $! rangeChunk v lo hi)
        pure (combineRanges (filter (\(a, _) -> a /= maxBound) rs))
  where
    !n = VU.length v
{-# NOINLINE rangeOf #-}

rangeChunk :: VU.Vector Int -> Int -> Int -> (Int, Int)
rangeChunk v lo hi = go lo maxBound minBound
  where
    go !i !mn !mx
        | i >= hi = (mn, mx)
        | otherwise =
            let !x = VU.unsafeIndex v i
             in go (i + 1) (min mn x) (max mx x)

combineRanges :: [(Int, Int)] -> (Int, Int)
combineRanges [] = (0, 0)
combineRanges ((a0, b0) : rest) = foldr (\(a, b) (ma, mb) -> (min ma a, max mb b)) (a0, b0) rest

-- | Whether to fan out at this row count.
shouldPar :: Int -> Bool
shouldPar = shouldParallelize parThreshold

-- | Contiguous per-worker row ranges, one chunk per capability when parallel.
rowChunks :: Int -> [(Int, Int)]
rowChunks = chunksFor parThreshold

-- | Like 'rowChunks' but over the code domain.
codeSlices :: Int -> [(Int, Int)]
codeSlices = chunksFor 4096

-- un each action on its own thread and collect results in order.

-- | Build the grouping by counting sort on a per-row code in @[0, card)@.
groupCodesMaybe ::
    (Int -> Int) ->
    Int ->
    Int ->
    (VU.Vector Int -> (VU.Vector Int, Int)) ->
    Maybe DirectGrouping
groupCodesMaybe codeAt n card mkGroups = do
    (rtg, offs, _reps, nGroups) <- directLayoutLazy codeAt n card mkGroups
    let !vis = visFromRowToGroup n nGroups offs rtg
    Just (DirectGrouping rtg vis offs nGroups)

-- | The layout every aggregation needs, without the O(n) stable placement.
directLayoutLazy ::
    (Int -> Int) ->
    Int ->
    Int ->
    (VU.Vector Int -> (VU.Vector Int, Int)) ->
    Maybe (VU.Vector Int, VU.Vector Int, VU.Vector Int, Int)
directLayoutLazy codeAt n card mkGroups
    | n <= 0 || card <= 0 = Nothing
    | useTwoLevel n card = unsafePerformIO (layoutWide codeAt n card mkGroups)
    | otherwise = unsafePerformIO (layoutNarrow codeAt n card mkGroups)
{-# NOINLINE directLayoutLazy #-}

-- | Narrow domains: per-chunk histograms with first-occurrence tracking.
layoutNarrow ::
    (Int -> Int) ->
    Int ->
    Int ->
    (VU.Vector Int -> (VU.Vector Int, Int)) ->
    IO (Maybe (VU.Vector Int, VU.Vector Int, VU.Vector Int, Int))
layoutNarrow codeAt n card mkGroups = do
    let chunks = rowChunks n
    parts <- forkJoin [histFirstChunk codeAt card lo hi | (lo, hi) <- chunks]
    if not (all (\(_, _, ok) -> ok) parts)
        then pure Nothing
        else do
            let partials = [(cs, fs) | (cs, fs, _) <- parts]
            totalsM <- VUM.unsafeNew card
            firstRowM <- VUM.unsafeNew card
            _ <-
                forkJoin
                    [mergeSlice partials totalsM firstRowM lo hi | (lo, hi) <- codeSlices card]
            counts <- VU.unsafeFreeze totalsM
            firstRow <- VU.unsafeFreeze firstRowM
            finishLayout codeAt n card mkGroups counts firstRow

-- | Histogram one row chunk into a private count, recording first rows.
histFirstChunk ::
    (Int -> Int) ->
    Int ->
    Int ->
    Int ->
    IO (VUM.IOVector Int, VUM.IOVector Int, Bool)
histFirstChunk codeAt card lo hi = do
    acc <- VUM.replicate card (0 :: Int)
    firstV <- VUM.unsafeNew card
    let go !i
            | i >= hi = pure True
            | otherwise = do
                let !c = codeAt i
                if c < 0 || c >= card
                    then pure False
                    else do
                        x <- VUM.unsafeRead acc c
                        when (x == 0) (VUM.unsafeWrite firstV c i)
                        VUM.unsafeWrite acc c (x + 1)
                        go (i + 1)
    ok <- go lo
    pure (acc, firstV, ok)

-- | Merge per-chunk counts and first rows over one slice of the code domain.
mergeSlice ::
    [(VUM.IOVector Int, VUM.IOVector Int)] ->
    VUM.IOVector Int ->
    VUM.IOVector Int ->
    Int ->
    Int ->
    IO ()
mergeSlice partials totals firstRow lo hi = go lo
  where
    go !c
        | c >= hi = pure ()
        | otherwise = do
            let sumP [] !acc = pure acc
                sumP ((cs, fs) : ps) !acc = do
                    x <- VUM.unsafeRead cs c
                    when (acc == 0 && x > 0) $
                        VUM.unsafeRead fs c >>= VUM.unsafeWrite firstRow c
                    sumP ps (acc + x)
            s <- sumP partials 0
            VUM.unsafeWrite totals c s
            go (c + 1)

-- | Wide domains: a two-level radix over packed @(code, row)@ words.
layoutWide ::
    (Int -> Int) ->
    Int ->
    Int ->
    (VU.Vector Int -> (VU.Vector Int, Int)) ->
    IO (Maybe (VU.Vector Int, VU.Vector Int, VU.Vector Int, Int))
layoutWide codeAt n card mkGroups = do
    mPacked <- packBucketed True codeAt n card
    case mPacked of
        Nothing -> pure Nothing
        Just (shift, bucketStart, packed) -> do
            countsM <- VUM.unsafeNew card
            firstRowM <- VUM.unsafeNew card
            overBuckets
                bucketStart
                n
                (countFirstBucket shift card bucketStart packed countsM firstRowM)
                (countFirstBucketPar shift card bucketStart packed countsM firstRowM)
            counts <- VU.unsafeFreeze countsM
            firstRow <- VU.unsafeFreeze firstRowM
            finishLayout codeAt n card mkGroups counts firstRow

-- | Shared tail of 'directLayoutLazy': mapping, offsets and representatives.
finishLayout ::
    (Int -> Int) ->
    Int ->
    Int ->
    (VU.Vector Int -> (VU.Vector Int, Int)) ->
    VU.Vector Int ->
    VU.Vector Int ->
    IO (Maybe (VU.Vector Int, VU.Vector Int, VU.Vector Int, Int))
finishLayout codeAt n card mkGroups counts firstRow = do
    let (!codeToGroup, !nGroups) = mkGroups counts
    offs <- scanOffsets counts codeToGroup nGroups
    repsM <- VUM.unsafeNew nGroups
    let repLoop !c
            | c >= card = pure ()
            | VU.unsafeIndex counts c == 0 = repLoop (c + 1)
            | otherwise = do
                VUM.unsafeWrite
                    repsM
                    (VU.unsafeIndex codeToGroup c)
                    (VU.unsafeIndex firstRow c)
                repLoop (c + 1)
    repLoop 0
    reps <- VU.unsafeFreeze repsM
    rtgM <- VUM.unsafeNew n
    -- An identity code map lets us skip the per-row table lookup.
    let identity = isIdentityMap codeToGroup
    _ <-
        forkJoin
            [ ( if identity
                    then rtgChunkIdentity codeAt rtgM lo hi
                    else rtgChunk codeAt codeToGroup rtgM lo hi
              )
            | (lo, hi) <- rowChunks n
            ]
    rtg <- VU.unsafeFreeze rtgM
    pure (Just (rtg, offs, reps, nGroups))

-- | Whether @codeToGroup@ maps every code to itself (fully occupied domain).
isIdentityMap :: VU.Vector Int -> Bool
isIdentityMap m = go 0
  where
    !k = VU.length m
    go !i
        | i >= k = True
        | VU.unsafeIndex m i /= i = False
        | otherwise = go (i + 1)

-- | Exclusive prefix scan of per-group counts into the offsets array.
scanOffsets :: VU.Vector Int -> VU.Vector Int -> Int -> IO (VU.Vector Int)
scanOffsets counts codeToGroup nGroups = do
    let !card = VU.length counts
    grpCount <- VUM.new nGroups
    let gather !c
            | c >= card = pure ()
            | otherwise = do
                let !cnt = VU.unsafeIndex counts c
                if cnt == 0
                    then gather (c + 1)
                    else do
                        VUM.unsafeWrite grpCount (VU.unsafeIndex codeToGroup c) cnt
                        gather (c + 1)
    gather 0
    offsM <- VUM.new (nGroups + 1)
    let scan !g !acc
            | g >= nGroups = VUM.unsafeWrite offsM nGroups acc
            | otherwise = do
                VUM.unsafeWrite offsM g acc
                c <- VUM.unsafeRead grpCount g
                scan (g + 1) (acc + c)
    scan 0 0
    VU.unsafeFreeze offsM

-- | @rowToGroup@ for one row chunk: remap each row's code through the table.
rtgChunk ::
    (Int -> Int) -> VU.Vector Int -> VUM.IOVector Int -> Int -> Int -> IO ()
rtgChunk codeAt codeToGroup rtgM lo hi = go lo
  where
    go !i
        | i >= hi = pure ()
        | otherwise = do
            let !c = codeAt i
            VUM.unsafeWrite rtgM i (VU.unsafeIndex codeToGroup c)
            go (i + 1)

-- | 'rtgChunk' without the remap lookup (codeToGroup is the identity).
rtgChunkIdentity :: (Int -> Int) -> VUM.IOVector Int -> Int -> Int -> IO ()
rtgChunkIdentity codeAt rtgM lo hi = go lo
  where
    go !i
        | i >= hi = pure ()
        | otherwise = do
            VUM.unsafeWrite rtgM i (codeAt i)
            go (i + 1)

-- | The unique stable counting-sort permutation of @rowToGroup@.
visFromRowToGroup ::
    Int -> Int -> VU.Vector Int -> VU.Vector Int -> VU.Vector Int
visFromRowToGroup n nGroups offs rtg
    | n <= 0 = VU.empty
    | useTwoLevel n nGroups = unsafePerformIO (visWide n nGroups offs rtg)
    | otherwise = unsafePerformIO (visNarrow n nGroups offs rtg)
{-# NOINLINE visFromRowToGroup #-}

-- | Narrow group domains: per-chunk histograms, then parallel stable placement.
visNarrow :: Int -> Int -> VU.Vector Int -> VU.Vector Int -> IO (VU.Vector Int)
visNarrow n nGroups offs rtg = do
    let chunks = rowChunks n
    hists <-
        forkJoin
            [ do
                acc <- VUM.replicate nGroups (0 :: Int)
                let go !i
                        | i >= hi = pure acc
                        | otherwise = do
                            let !g = VU.unsafeIndex rtg i
                            x <- VUM.unsafeRead acc g
                            VUM.unsafeWrite acc g (x + 1)
                            go (i + 1)
                go lo
            | (lo, hi) <- chunks
            ]
    -- Each chunk histogram becomes its write cursor: offs[g] + earlier chunks.
    _ <-
        forkJoin
            [ let seed !g
                    | g >= hi = pure ()
                    | otherwise = do
                        let inner [] !_ = pure ()
                            inner (h : hs) !a = do
                                t <- VUM.unsafeRead h g
                                VUM.unsafeWrite h g a
                                inner hs (a + t)
                        inner hists (VU.unsafeIndex offs g)
                        seed (g + 1)
               in seed lo
            | (lo, hi) <- codeSlices nGroups
            ]
    visM <- VUM.unsafeNew n
    _ <-
        forkJoin
            [ let place !i
                    | i >= hi = pure ()
                    | otherwise = do
                        let !g = VU.unsafeIndex rtg i
                        p <- VUM.unsafeRead cursor g
                        VUM.unsafeWrite visM p i
                        VUM.unsafeWrite cursor g (p + 1)
                        place (i + 1)
               in place lo
            | ((lo, hi), cursor) <- zip chunks hists
            ]
    VU.unsafeFreeze visM

-- | Wide group domains: two-level radix placement into @valueIndices@.
visWide :: Int -> Int -> VU.Vector Int -> VU.Vector Int -> IO (VU.Vector Int)
visWide n nGroups offs rtg = do
    mPacked <- packBucketed False (VU.unsafeIndex rtg) n nGroups
    case mPacked of
        Nothing -> visNarrow n nGroups offs rtg -- unreachable: no validation
        Just (shift, bucketStart, packed) -> do
            visM <- VUM.unsafeNew n
            overBuckets
                bucketStart
                n
                (placeBucketOffs shift nGroups bucketStart offs packed visM)
                (placeBucketOffsPar shift nGroups bucketStart offs packed visM)
            VU.unsafeFreeze visM

-- | Stable placement of one bucket's packed slice at offset-seeded cursors.
placeBucketOffs ::
    Int ->
    Int ->
    VU.Vector Int ->
    VU.Vector Int ->
    PackedBuckets ->
    VUM.IOVector Int ->
    Int ->
    IO ()
placeBucketOffs shift card bucketStart offs (PackedBuckets packed) visM b = do
    let !s = VU.unsafeIndex bucketStart b
        !e = VU.unsafeIndex bucketStart (b + 1)
        !base = b `unsafeShiftL` shift
        !range = min (1 `unsafeShiftL` shift) (card - base)
    cursor <- VUM.unsafeNew range
    let initC !j
            | j >= range = pure ()
            | otherwise = do
                VUM.unsafeWrite cursor j (VU.unsafeIndex offs (base + j))
                initC (j + 1)
    initC 0
    let place !pos
            | pos >= e = pure ()
            | otherwise = do
                pc <- VUM.unsafeRead packed pos
                let !j = (pc `unsafeShiftR` packShift) - base
                p <- VUM.unsafeRead cursor j
                VUM.unsafeWrite visM p (pc .&. packRowMask)
                VUM.unsafeWrite cursor j (p + 1)
                place (pos + 1)
    place s

-- | 'placeBucketOffs' for an oversized bucket, split into sub-chunks.
placeBucketOffsPar ::
    Int ->
    Int ->
    VU.Vector Int ->
    VU.Vector Int ->
    PackedBuckets ->
    VUM.IOVector Int ->
    Int ->
    IO ()
placeBucketOffsPar shift card bucketStart offs pb visM b = do
    let !s = VU.unsafeIndex bucketStart b
        !e = VU.unsafeIndex bucketStart (b + 1)
        !base = b `unsafeShiftL` shift
        !range = min (1 `unsafeShiftL` shift) (card - base)
        packed = packedVec pb
        subChunks = [(s + lo, s + hi) | (lo, hi) <- splitChunkRange capabilities (e - s)]
    hists <-
        forkJoin
            [subHist pb base range lo hi | (lo, hi) <- subChunks]
    let seed !j
            | j >= range = pure ()
            | otherwise = do
                let inner [] !_ = pure ()
                    inner (h : hs) !a = do
                        t <- VUM.unsafeRead h j
                        VUM.unsafeWrite h j a
                        inner hs (a + t)
                inner hists (VU.unsafeIndex offs (base + j))
                seed (j + 1)
    seed 0
    _ <-
        forkJoin
            [ let place !pos
                    | pos >= hi = pure ()
                    | otherwise = do
                        pc <- VUM.unsafeRead packed pos
                        let !j = (pc `unsafeShiftR` packShift) - base
                        p <- VUM.unsafeRead cursor j
                        VUM.unsafeWrite visM p (pc .&. packRowMask)
                        VUM.unsafeWrite cursor j (p + 1)
                        place (pos + 1)
               in place lo
            | ((lo, hi), cursor) <- zip subChunks hists
            ]
    pure ()

-- | Code-domain size above which parallel passes use two-level radix.
twoLevelCardThreshold :: Int
twoLevelCardThreshold = 1024

-- | The two-level engine packs @(code, row)@ into one machine word.
packShift :: Int
packShift = 40

packRowMask :: Int
packRowMask = (1 `unsafeShiftL` packShift) - 1

-- | Bucket-count target of the two-level engine (@2^10@ buckets).
bucketBits :: Int
bucketBits = 10

-- | Use the two-level engine? (Parallel-scale @n@, wide but packable domain.)
useTwoLevel :: Int -> Int -> Bool
useTwoLevel n card =
    shouldPar n
        && card > twoLevelCardThreshold
        && card <= (1 `unsafeShiftL` 22)
        && n <= packRowMask

-- | @ceilLog2 x@: smallest @s@ with @2^s >= x@ (for @x >= 1@).
ceilLog2 :: Int -> Int
ceilLog2 x
    | x <= 1 = 0
    | otherwise = 64 - countLeadingZeros (x - 1)

-- | The bucket store: rows partitioned by the top code bits.
newtype PackedBuckets = PackedBuckets (VUM.IOVector Int)

packedVec :: PackedBuckets -> VUM.IOVector Int
packedVec (PackedBuckets v) = v

-- | Partition rows into buckets by top code bits as packed words.
packBucketed ::
    Bool ->
    (Int -> Int) ->
    Int ->
    Int ->
    IO (Maybe (Int, VU.Vector Int, PackedBuckets))
packBucketed validate codeAt n card = do
    let !shift = max 0 (ceilLog2 card - bucketBits)
        !nBuckets = ((card - 1) `unsafeShiftR` shift) + 1
        chunks = rowChunks n
    parts <-
        forkJoin
            [bucketHist validate codeAt card shift nBuckets lo hi | (lo, hi) <- chunks]
    if not (all snd parts)
        then pure Nothing
        else do
            let cursors = map fst parts
            bucketStartM <- VUM.unsafeNew (nBuckets + 1)
            let seed !b !acc
                    | b >= nBuckets = VUM.unsafeWrite bucketStartM nBuckets acc
                    | otherwise = do
                        VUM.unsafeWrite bucketStartM b acc
                        let inner [] !a = pure a
                            inner (cur : rest) !a = do
                                t <- VUM.unsafeRead cur b
                                VUM.unsafeWrite cur b a
                                inner rest (a + t)
                        acc' <- inner cursors acc
                        seed (b + 1) acc'
            seed 0 0
            bucketStart <- VU.unsafeFreeze bucketStartM
            packed <- VUM.unsafeNew n
            _ <-
                forkJoin
                    [ scatterPacked codeAt shift cur packed lo hi
                    | ((lo, hi), cur) <- zip chunks cursors
                    ]
            pure (Just (shift, bucketStart, PackedBuckets packed))

-- | Histogram one row chunk by bucket (top code bits) into a private count.
bucketHist ::
    Bool ->
    (Int -> Int) ->
    Int ->
    Int ->
    Int ->
    Int ->
    Int ->
    IO (VUM.IOVector Int, Bool)
bucketHist validate codeAt card shift nBuckets lo hi = do
    acc <- VUM.replicate nBuckets (0 :: Int)
    let bump !c !i = do
            let !b = c `unsafeShiftR` shift
            x <- VUM.unsafeRead acc b
            VUM.unsafeWrite acc b (x + 1)
            go (i + 1)
        go !i
            | i >= hi = pure True
            | otherwise = do
                let !c = codeAt i
                if validate && (c < 0 || c >= card)
                    then pure False
                    else bump c i
    ok <- go lo
    pure (acc, ok)

-- | Scatter one row chunk's packed words through its bucket cursor.
scatterPacked ::
    (Int -> Int) ->
    Int ->
    VUM.IOVector Int ->
    VUM.IOVector Int ->
    Int ->
    Int ->
    IO ()
scatterPacked codeAt shift cursor packed lo hi = go lo
  where
    go !i
        | i >= hi = pure ()
        | otherwise = do
            let !c = codeAt i
                !b = c `unsafeShiftR` shift
            pos <- VUM.unsafeRead cursor b
            VUM.unsafeWrite packed pos ((c `unsafeShiftL` packShift) + i)
            VUM.unsafeWrite cursor b (pos + 1)
            go (i + 1)

-- | Drive one action per bucket, with oversized buckets run first.
overBuckets :: VU.Vector Int -> Int -> (Int -> IO ()) -> (Int -> IO ()) -> IO ()
overBuckets bucketStart n small big = do
    let !nBuckets = VU.length bucketStart - 1
        !bigCut = max parThreshold (2 * (n `div` max 1 capabilities))
        size b = VU.unsafeIndex bucketStart (b + 1) - VU.unsafeIndex bucketStart b
    mapM_ big [b | b <- [0 .. nBuckets - 1], size b >= bigCut]
    pooledIndices capabilities nBuckets $ \b ->
        when (size b < bigCut) (small b)

-- | One bucket's exact per-code counts and first rows from its packed slice.
countFirstBucket ::
    Int ->
    Int ->
    VU.Vector Int ->
    PackedBuckets ->
    VUM.IOVector Int ->
    VUM.IOVector Int ->
    Int ->
    IO ()
countFirstBucket shift card bucketStart (PackedBuckets packed) countsM firstRowM b = do
    let !s = VU.unsafeIndex bucketStart b
        !e = VU.unsafeIndex bucketStart (b + 1)
        !base = b `unsafeShiftL` shift
        !range = min (1 `unsafeShiftL` shift) (card - base)
    local <- VUM.replicate range (0 :: Int)
    let hist !pos
            | pos >= e = pure ()
            | otherwise = do
                pc <- VUM.unsafeRead packed pos
                let !j = (pc `unsafeShiftR` packShift) - base
                x <- VUM.unsafeRead local j
                when (x == 0) $
                    VUM.unsafeWrite firstRowM (base + j) (pc .&. packRowMask)
                VUM.unsafeWrite local j (x + 1)
                hist (pos + 1)
    hist s
    let flush !j
            | j >= range = pure ()
            | otherwise = do
                t <- VUM.unsafeRead local j
                VUM.unsafeWrite countsM (base + j) t
                flush (j + 1)
    flush 0

-- | 'countFirstBucket' for an oversized bucket, chunked across capabilities.
countFirstBucketPar ::
    Int ->
    Int ->
    VU.Vector Int ->
    PackedBuckets ->
    VUM.IOVector Int ->
    VUM.IOVector Int ->
    Int ->
    IO ()
countFirstBucketPar shift card bucketStart pb countsM firstRowM b = do
    let !s = VU.unsafeIndex bucketStart b
        !e = VU.unsafeIndex bucketStart (b + 1)
        !base = b `unsafeShiftL` shift
        !range = min (1 `unsafeShiftL` shift) (card - base)
        packed = packedVec pb
        subChunks = [(s + lo, s + hi) | (lo, hi) <- splitChunkRange capabilities (e - s)]
    parts <-
        forkJoin
            [ do
                local <- VUM.replicate range (0 :: Int)
                firstL <- VUM.unsafeNew range
                let hist !pos
                        | pos >= hi = pure (local, firstL)
                        | otherwise = do
                            pc <- VUM.unsafeRead packed pos
                            let !j = (pc `unsafeShiftR` packShift) - base
                            x <- VUM.unsafeRead local j
                            when (x == 0) $
                                VUM.unsafeWrite firstL j (pc .&. packRowMask)
                            VUM.unsafeWrite local j (x + 1)
                            hist (pos + 1)
                hist lo
            | (lo, hi) <- subChunks
            ]
    -- Merge in sub-chunk (= row) order: totals and global first occurrence.
    let merge !j
            | j >= range = pure ()
            | otherwise = do
                let inner [] !acc = pure acc
                    inner ((cs, fs) : ps) !acc = do
                        x <- VUM.unsafeRead cs j
                        when (acc == 0 && x > 0) $
                            VUM.unsafeRead fs j >>= VUM.unsafeWrite firstRowM (base + j)
                        inner ps (acc + x)
                t <- inner parts 0
                VUM.unsafeWrite countsM (base + j) t
                merge (j + 1)
    merge 0

-- | Per-sub-chunk histogram of one bucket's packed slice (codes only).
subHist :: PackedBuckets -> Int -> Int -> Int -> Int -> IO (VUM.IOVector Int)
subHist (PackedBuckets packed) base range lo hi = do
    acc <- VUM.replicate range (0 :: Int)
    let go !pos
            | pos >= hi = pure acc
            | otherwise = do
                pc <- VUM.unsafeRead packed pos
                let !j = (pc `unsafeShiftR` packShift) - base
                x <- VUM.unsafeRead acc j
                VUM.unsafeWrite acc j (x + 1)
                go (pos + 1)
    go lo

-- | The ascending-code group order: dense ids for non-empty codes.
ascendingCodeGroups :: VU.Vector Int -> (VU.Vector Int, Int)
ascendingCodeGroups counts = runST $ do
    let !card = VU.length counts
    m <- VUM.new card
    let go !c !next
            | c >= card = pure next
            | VU.unsafeIndex counts c > 0 = do
                VUM.unsafeWrite m c next
                go (c + 1) (next + 1)
            | otherwise = go (c + 1) next
    nGroups <- go 0 0
    frozen <- VU.unsafeFreeze m
    pure (frozen, nGroups)