packages feed

dataframe-core-2.6.0.0: src-internal/DataFrame/Internal/Aggregation/Kernel/Distinct.hs

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

module DataFrame.Internal.Aggregation.Kernel.Distinct (
    distinctSupported,
    countDistinctColumn,
    countDistinctGroups,
) where

import Control.Monad (forM_, replicateM, void, when)
import Data.Bits (
    countLeadingZeros,
    finiteBitSize,
    unsafeShiftL,
    unsafeShiftR,
    xor,
    (.&.),
 )
import Data.Char (ord)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
import Data.Int (Int16, Int32, Int64, Int8)
import Data.Primitive.ByteArray (MutableByteArray (..))
import qualified Data.Text.Array as A
import Data.Text.Internal (Text (Text))
import Data.Type.Equality (TestEquality (..), type (:~:) (Refl))
import qualified Data.Vector as V
import qualified Data.Vector.Primitive as VP
import qualified Data.Vector.Primitive.Mutable as VPM
import qualified Data.Vector.Unboxed as VU
import Data.Vector.Unboxed.Base (
    MVector (MV_Int),
    Vector (V_Int, V_Int64, V_Word64),
 )
import qualified Data.Vector.Unboxed.Mutable as VUM
import Data.Word (Word16, Word32, Word64, Word8)
import GHC.Exts (Int (I#), fetchAddIntArray#, (+#))
import GHC.Float (castDoubleToWord64)
import GHC.IO (IO (IO))
import System.IO.Unsafe (unsafePerformIO)
import Type.Reflection (typeRep)

import DataFrame.Internal.Algorithms.Hash (fnvOffset, mixBytes)
import DataFrame.Internal.Column (Column (..))
import DataFrame.Internal.Control.Concurrent (
    capabilities,
    forkJoin_,
    parThreshold,
    shouldParallelize,
    splitChunkRange,
 )
import DataFrame.Internal.Data.PackedText (
    PackedOffsets (..),
    PackedTextData,
    packedLength,
    packedRowOffsets,
    packedSlice,
 )

-- | How a column's rows become set keys.
data Keys
    = -- | One 64-bit word per row; equal words iff equal values.
      WordKeys !(VU.Vector Int)
    | -- | A UTF-8 byte slice per row.
      ByteKeys !Int !TextRows

-- | Where a text row's bytes live, with kernel instances for each layout.
data TextRows
    = Contig32 !A.Array !(VU.Vector Int32)
    | Contig64 !A.Array !(VU.Vector Int)
    | AnyRows (Int -> (A.Array, Int, Int))

-- | Whether 'countDistinctColumn' / 'countDistinctGroups' accept this column.
distinctSupported :: Column -> Bool
distinctSupported c = case columnKeys c of
    Just _ -> True
    Nothing -> False

columnKeys :: Column -> Maybe Keys
columnKeys col = case col of
    UnboxedColumn Nothing (v :: VU.Vector a)
        | Just Refl <- testEquality (typeRep @a) (typeRep @Int) -> Just (WordKeys v)
        | Just Refl <- testEquality (typeRep @a) (typeRep @Int64) ->
            Just (WordKeys (int64Words v))
        | Just Refl <- testEquality (typeRep @a) (typeRep @Word64) ->
            Just (WordKeys (word64Words v))
        | Just Refl <- testEquality (typeRep @a) (typeRep @Int32) -> widen v
        | Just Refl <- testEquality (typeRep @a) (typeRep @Int16) -> widen v
        | Just Refl <- testEquality (typeRep @a) (typeRep @Int8) -> widen v
        | Just Refl <- testEquality (typeRep @a) (typeRep @Word) -> widen v
        | Just Refl <- testEquality (typeRep @a) (typeRep @Word32) -> widen v
        | Just Refl <- testEquality (typeRep @a) (typeRep @Word16) -> widen v
        | Just Refl <- testEquality (typeRep @a) (typeRep @Word8) -> widen v
        | Just Refl <- testEquality (typeRep @a) (typeRep @Double) ->
            Just (WordKeys (VU.map doubleWord v))
        | Just Refl <- testEquality (typeRep @a) (typeRep @Char) ->
            Just (WordKeys (VU.map ord v))
        | Just Refl <- testEquality (typeRep @a) (typeRep @Bool) ->
            Just (WordKeys (VU.map fromEnum v))
    BoxedColumn Nothing (v :: V.Vector a)
        | Just Refl <- testEquality (typeRep @a) (typeRep @Text) ->
            Just
                ( ByteKeys
                    (V.length v)
                    (AnyRows (\i -> case V.unsafeIndex v i of Text arr o l -> (arr, o, l)))
                )
    PackedText Nothing p -> Just (ByteKeys (packedLength p) (packedRows p))
    _ -> Nothing
  where
    widen :: (Integral b, VU.Unbox b) => VU.Vector b -> Maybe Keys
    widen = Just . WordKeys . VU.map fromIntegral

packedRows :: PackedTextData -> TextRows
packedRows p = case packedRowOffsets p of
    Just (arr, Offs32 offs) -> Contig32 arr offs
    Just (arr, Offs64 offs) -> Contig64 arr offs
    Nothing -> AnyRows (packedSlice p)

-- | Reinterpret 64-bit words as 'Int' without copying (same width and layout).
int64Words :: VU.Vector Int64 -> VU.Vector Int
int64Words v@(V_Int64 (VP.Vector o l ba))
    | finiteBitSize (0 :: Int) == 64 = V_Int (VP.Vector o l ba)
    | otherwise = VU.map fromIntegral v

word64Words :: VU.Vector Word64 -> VU.Vector Int
word64Words v@(V_Word64 (VP.Vector o l ba))
    | finiteBitSize (0 :: Int) == 64 = V_Int (VP.Vector o l ba)
    | otherwise = VU.map fromIntegral v

-- | Equal doubles share a word: @-0.0@ joins @0.0@ and every NaN is one value.
doubleWord :: Double -> Int
doubleWord d
    | isNaN d = 0x7ff8000000000000
    | d == 0 = 0
    | otherwise = fromIntegral (castDoubleToWord64 d)

-- | Number of distinct values in a column, or 'Nothing' if not admitted.
countDistinctColumn :: Column -> Maybe Int
countDistinctColumn col = case columnKeys col of
    Nothing -> Nothing
    Just (WordKeys v) -> Just $! unsafePerformIO (wordGlobal v)
    Just (ByteKeys n rows) -> Just $! unsafePerformIO $ case rows of
        Contig32 arr offs -> bytesGlobal n (contigSlice arr offs)
        Contig64 arr offs -> bytesGlobal n (contigSlice arr offs)
        AnyRows slice -> bytesGlobal n slice
{-# NOINLINE countDistinctColumn #-}

-- | Distinct values per group, or 'Nothing' if the column is not admitted.
countDistinctGroups :: VU.Vector Int -> Int -> Column -> Maybe (VU.Vector Int)
countDistinctGroups rtg nGroups col = case columnKeys col of
    Nothing -> Nothing
    Just (WordKeys v) -> Just $! unsafePerformIO (wordGrouped rtg nGroups v)
    Just (ByteKeys _ rows) -> Just $! unsafePerformIO $ case rows of
        Contig32 arr offs -> bytesGrouped rtg nGroups (contigSlice arr offs)
        Contig64 arr offs -> bytesGrouped rtg nGroups (contigSlice arr offs)
        AnyRows slice -> bytesGrouped rtg nGroups slice
{-# NOINLINE countDistinctGroups #-}

contigSlice ::
    (Integral o, VU.Unbox o) => A.Array -> VU.Vector o -> Int -> (A.Array, Int, Int)
contigSlice arr offs i =
    let !o = fromIntegral (VU.unsafeIndex offs i)
     in (arr, o, fromIntegral (VU.unsafeIndex offs (i + 1)) - o)
{-# INLINE contigSlice #-}

wordGlobal :: VU.Vector Int -> IO Int
wordGlobal v = do
    let !n = VU.length v
        !pb = partitionBits n
        key = VU.unsafeIndex v
    Scattered w0 _ starts <-
        scatterRows
            n
            pb
            False
            False
            (fmix . key)
            (\r r' -> key r == key r')
            (\i _ -> key i)
            (const 0)
    counts <- forPartitions (VU.length starts - 1) (newIORef (0 :: Int)) $ \acc p -> do
        c <- dedupWords pb w0 (VU.unsafeIndex starts p) (VU.unsafeIndex starts (p + 1))
        modifyStrict acc (+ c)
    sum <$> mapM readIORef counts

wordGrouped :: VU.Vector Int -> Int -> VU.Vector Int -> IO (VU.Vector Int)
wordGrouped rtg nGroups v = do
    let !n = VU.length v
        !pb = partitionBits n
        key = VU.unsafeIndex v
        gid = VU.unsafeIndex rtg
    Scattered w0 w1 starts <-
        scatterRows
            n
            pb
            False
            True
            (\i -> pairHash (gid i) (key i))
            (\r r' -> key r == key r' && gid r == gid r')
            (\i _ -> key i)
            gid
    groupCounts nGroups (VU.length starts - 1) $ \counts p ->
        void $
            dedupPairs
                pb
                w0
                w1
                (VU.unsafeIndex starts p)
                (VU.unsafeIndex starts (p + 1))
                pairHash'
                (\a0 a1 b0 b1 -> a0 == b0 && a1 == b1)
                (\_ g -> bumpGroup counts g)
  where
    pairHash' k g = pairHash g k

bytesGlobal :: Int -> (Int -> (A.Array, Int, Int)) -> IO Int
bytesGlobal n slice = do
    let !pb = partitionBits n
        hashRow i = case slice i of (arr, o, l) -> fmix (mixBytes fnvOffset arr o l)
    Scattered w0 w1 starts <-
        scatterRows n pb True True hashRow (rowsEq slice) (\_ h -> h) id
    counts <- forPartitions (VU.length starts - 1) (newIORef (0 :: Int)) $ \acc p -> do
        c <-
            dedupPairs
                pb
                w0
                w1
                (VU.unsafeIndex starts p)
                (VU.unsafeIndex starts (p + 1))
                const
                (\h r h' r' -> h == h' && rowsEq slice r r')
                (\_ _ -> pure ())
        modifyStrict acc (+ c)
    sum <$> mapM readIORef counts

bytesGrouped ::
    VU.Vector Int -> Int -> (Int -> (A.Array, Int, Int)) -> IO (VU.Vector Int)
bytesGrouped rtg nGroups slice = do
    let !n = VU.length rtg
        !pb = partitionBits n
        gid = VU.unsafeIndex rtg
        hashRow i = case slice i of
            (arr, o, l) -> pairHash (gid i) (mixBytes fnvOffset arr o l)
    Scattered w0 w1 starts <-
        scatterRows
            n
            pb
            True
            True
            hashRow
            (\r r' -> gid r == gid r' && rowsEq slice r r')
            (\_ h -> h)
            id
    groupCounts nGroups (VU.length starts - 1) $ \counts p ->
        void $
            dedupPairs
                pb
                w0
                w1
                (VU.unsafeIndex starts p)
                (VU.unsafeIndex starts (p + 1))
                const
                (\h r h' r' -> h == h' && gid r == gid r' && rowsEq slice r r')
                (\_ r -> bumpGroup counts (gid r))

rowsEq :: (Int -> (A.Array, Int, Int)) -> Int -> Int -> Bool
rowsEq slice r r' = case slice r of
    (a, o, l) -> case slice r' of
        (a', o', l') -> l == l' && A.compare a o a' o' l == EQ
{-# INLINE rowsEq #-}

{-# INLINE bytesGlobal #-}
{-# INLINE bytesGrouped #-}

-- | Murmur3's 64-bit finaliser: every input bit reaches every output bit.
fmix :: Int -> Int
fmix k =
    let !h0 = fromIntegral k :: Word64
        !h1 = (h0 `xor` (h0 `unsafeShiftR` 33)) * 0xff51afd7ed558ccd
        !h2 = (h1 `xor` (h1 `unsafeShiftR` 33)) * 0xc4ceb9fe1a85ec53
     in fromIntegral (h2 `xor` (h2 `unsafeShiftR` 33))
{-# INLINE fmix #-}

pairHash :: Int -> Int -> Int
pairHash g k = fmix (k `xor` (g * 0x9E3779B97F4A7C1))
{-# INLINE pairHash #-}

-- | Partition of a hash: its top @pb@ bits (@pb >= 1@).
partOf :: Int -> Int -> Int
partOf pb h = fromIntegral ((fromIntegral h :: Word64) `unsafeShiftR` (64 - pb))
{-# INLINE partOf #-}

-- | Slot of a hash in a @2^bits@ table: the bits just below the partition bits.
slotOf :: Int -> Int -> Int -> Int
slotOf pb bits h =
    fromIntegral
        (((fromIntegral h :: Word64) `unsafeShiftL` pb) `unsafeShiftR` (64 - bits))
{-# INLINE slotOf #-}

-- | Partition count as a bit width, sized for cache-resident sets.
partitionBits :: Int -> Int
partitionBits n
    | not (shouldParallelize parThreshold n) = 1
    | otherwise =
        max
            1
            ( min
                maxPartitionBits
                (max (ceilLog2 (n `div` rowsPerPartition)) (ceilLog2 (4 * capabilities)))
            )

maxPartitionBits :: Int
maxPartitionBits = 8

rowsPerPartition :: Int
rowsPerPartition = 65536

ceilLog2 :: Int -> Int
ceilLog2 x
    | x <= 1 = 0
    | otherwise = finiteBitSize x - countLeadingZeros (x - 1)

-- | Entries in each chunk's cache of recently emitted keys.
cacheSize :: Int
cacheSize = 4096

-- | Partition mark of a row dropped as a cached duplicate.
droppedRow :: Word16
droppedRow = maxBound

-- | Rows scattered by partition: key words, optional second words, starts.
data Scattered = Scattered !(VU.Vector Int) !(VU.Vector Int) !(VU.Vector Int)

-- | Hash every row to a partition, then write each row's words into it.
scatterRows ::
    Int ->
    Int ->
    -- | keep the hash between passes
    Bool ->
    -- | write a second word per row
    Bool ->
    -- | row hash
    (Int -> Int) ->
    -- | do two rows hold the same key?
    (Int -> Int -> Bool) ->
    -- | first word, from row and hash
    (Int -> Int -> Int) ->
    -- | second word, from row
    (Int -> Int) ->
    IO Scattered
scatterRows n pb keepHash twoWords rowHash rowEq rowW0 rowW1 = do
    let !np = 1 `unsafeShiftL` pb
        chunks
            | shouldParallelize parThreshold n = splitChunkRange capabilities n
            | otherwise = [(0, n)]
        !nc = length chunks
    hashes <- if keepHash then VUM.unsafeNew n else VUM.unsafeNew 0
    parts <- VUM.unsafeNew n :: IO (VUM.IOVector Word16)
    hist <- VUM.replicate (nc * np) (0 :: Int)
    forkJoin_
        [ do
            cacheHash <- VUM.unsafeNew cacheSize
            cacheRow <- VUM.replicate cacheSize (-1 :: Int)
            let !base = ci * np
                go !i
                    | i >= hi = pure ()
                    | otherwise = do
                        let !h = rowHash i
                            !c = h .&. (cacheSize - 1)
                        when keepHash (VUM.unsafeWrite hashes i h)
                        r <- VUM.unsafeRead cacheRow c
                        dup <-
                            if r < 0
                                then pure False
                                else do
                                    h' <- VUM.unsafeRead cacheHash c
                                    pure (h' == h && rowEq r i)
                        if dup
                            then VUM.unsafeWrite parts i droppedRow
                            else do
                                VUM.unsafeWrite cacheHash c h
                                VUM.unsafeWrite cacheRow c i
                                let !p = partOf pb h
                                VUM.unsafeWrite parts i (fromIntegral p)
                                VUM.unsafeModify hist (+ 1) (base + p)
                        go (i + 1)
            go lo
        | (ci, (lo, hi)) <- zip [0 ..] chunks
        ]
    -- Exclusive prefix sum, partition-major, turns counts into write cursors.
    starts <- VUM.unsafeNew (np + 1)
    let prefix !p !run
            | p >= np = VUM.unsafeWrite starts np run
            | otherwise = do
                VUM.unsafeWrite starts p run
                let walk !ci !r
                        | ci >= nc = pure r
                        | otherwise = do
                            c <- VUM.unsafeRead hist (ci * np + p)
                            VUM.unsafeWrite hist (ci * np + p) r
                            walk (ci + 1) (r + c)
                run' <- walk 0 run
                prefix (p + 1) run'
    prefix 0 0
    kept <- VUM.unsafeRead starts np
    w0 <- VUM.unsafeNew kept
    w1 <- if twoWords then VUM.unsafeNew kept else VUM.unsafeNew 0
    forkJoin_
        [ let !base = ci * np
              go !i
                | i >= hi = pure ()
                | otherwise = do
                    p <- VUM.unsafeRead parts i
                    when (p /= droppedRow) $ do
                        h <- if keepHash then VUM.unsafeRead hashes i else pure 0
                        let !slot = base + fromIntegral p
                        pos <- VUM.unsafeRead hist slot
                        VUM.unsafeWrite hist slot (pos + 1)
                        VUM.unsafeWrite w0 pos (rowW0 i h)
                        when twoWords (VUM.unsafeWrite w1 pos (rowW1 i))
                    go (i + 1)
           in go lo
        | (ci, (lo, hi)) <- zip [0 ..] chunks
        ]
    Scattered
        <$> VU.unsafeFreeze w0
        <*> VU.unsafeFreeze w1
        <*> VU.unsafeFreeze starts
{-# INLINE scatterRows #-}

-- | Run @body@ over partitions on a worker pool pulling from a shared counter.
forPartitions :: Int -> IO s -> (s -> Int -> IO ()) -> IO [s]
forPartitions np newState body = do
    next <- newIORef 0
    let !width = if np > 1 && capabilities > 1 then min capabilities np else 1
    states <- replicateM width newState
    let worker st = do
            p <- atomicModifyIORef' next (\j -> (j + 1, j))
            when (p < np) (body st p >> worker st)
    forkJoin_ (map worker states)
    pure states
{-# INLINE forPartitions #-}

-- | Per-group counts from a body that bumps a counter once per new pair.
groupCounts :: Int -> Int -> (Counts -> Int -> IO ()) -> IO (VU.Vector Int)
groupCounts nGroups np body
    | nGroups * capabilities <= 1 `unsafeShiftL` 22 = do
        arrs <- forPartitions np (VUM.replicate nGroups 0) $ \arr p ->
            body (Counts False arr) p
        total <- VUM.replicate nGroups 0
        forM_ arrs $ \arr ->
            forM_ [0 .. nGroups - 1] $ \g -> do
                c <- VUM.unsafeRead arr g
                VUM.unsafeModify total (+ c) g
        VU.unsafeFreeze total
    | otherwise = do
        shared <- VUM.replicate nGroups 0
        _ <- forPartitions np (pure ()) $ \() p -> body (Counts True shared) p
        VU.unsafeFreeze shared
{-# INLINE groupCounts #-}

-- | A group-count array and whether other workers share it.
data Counts = Counts !Bool !(VUM.IOVector Int)

bumpGroup :: Counts -> Int -> IO ()
bumpGroup (Counts shared arr) g
    | shared = atomicIncr arr g
    | otherwise = VUM.unsafeModify arr (+ 1) g
{-# INLINE bumpGroup #-}

atomicIncr :: VUM.IOVector Int -> Int -> IO ()
atomicIncr (MV_Int (VPM.MVector (I# off) _ (MutableByteArray mba))) (I# i) =
    IO
        (\s -> case fetchAddIntArray# mba (off +# i) 1# s of (# s', _ #) -> (# s', () #))
{-# INLINE atomicIncr #-}

modifyStrict :: IORef Int -> (Int -> Int) -> IO ()
modifyStrict ref f = readIORef ref >>= \x -> writeIORef ref $! f x
{-# INLINE modifyStrict #-}

-- | Starting table width for a partition of @rows@ entries.
initialBits :: Int -> Int
initialBits rows = max 4 (min 12 (ceilLog2 (2 * rows)))

-- | Distinct words in @w0[s, e)@, with 'minBound' tracked by a flag.
dedupWords :: Int -> VU.Vector Int -> Int -> Int -> IO Int
dedupWords pb w0 s e = do
    let !bits0 = initialBits (e - s)
    t0 <- VUM.replicate (1 `unsafeShiftL` bits0) emptyWord
    let go !t !bits !cnt !sawEmpty !i
            | i >= e = pure (if sawEmpty then cnt + 1 else cnt)
            | otherwise = do
                let !k = VU.unsafeIndex w0 i
                if k == emptyWord
                    then go t bits cnt True (i + 1)
                    else do
                        let !mask = (1 `unsafeShiftL` bits) - 1
                            probe !sl = do
                                x <- VUM.unsafeRead t sl
                                if x == emptyWord
                                    then VUM.unsafeWrite t sl k >> pure True
                                    else
                                        if x == k
                                            then pure False
                                            else probe ((sl + 1) .&. mask)
                        new <- probe (slotOf pb bits (fmix k))
                        if not new
                            then go t bits cnt sawEmpty (i + 1)
                            else
                                if 2 * (cnt + 1) > mask + 1
                                    then do
                                        t' <- growWords pb t bits
                                        go t' (bits + 1) (cnt + 1) sawEmpty (i + 1)
                                    else go t bits (cnt + 1) sawEmpty (i + 1)
    go t0 bits0 0 False s

emptyWord :: Int
emptyWord = minBound

growWords :: Int -> VUM.IOVector Int -> Int -> IO (VUM.IOVector Int)
growWords pb t bits = do
    let !bits' = bits + 1
        !mask' = (1 `unsafeShiftL` bits') - 1
    t' <- VUM.replicate (mask' + 1) emptyWord
    forM_ [0 .. VUM.length t - 1] $ \j -> do
        k <- VUM.unsafeRead t j
        when (k /= emptyWord) $ do
            let place !sl = do
                    x <- VUM.unsafeRead t' sl
                    if x == emptyWord
                        then VUM.unsafeWrite t' sl k
                        else place ((sl + 1) .&. mask')
            place (slotOf pb bits' (fmix k))
    pure t'

-- | Distinct @(w0, w1)@ entries in @[s, e)@ under @eq@, slotted by @hashOf@.
dedupPairs ::
    Int ->
    VU.Vector Int ->
    VU.Vector Int ->
    Int ->
    Int ->
    (Int -> Int -> Int) ->
    (Int -> Int -> Int -> Int -> Bool) ->
    (Int -> Int -> IO ()) ->
    IO Int
dedupPairs pb w0 w1 s e hashOf eq onNew = do
    let !bits0 = initialBits (e - s)
    a0 <- VUM.unsafeNew (1 `unsafeShiftL` bits0)
    a1 <- VUM.replicate (1 `unsafeShiftL` bits0) (-1)
    let go !t0 !t1 !bits !cnt !i
            | i >= e = pure cnt
            | otherwise = do
                let !k0 = VU.unsafeIndex w0 i
                    !k1 = VU.unsafeIndex w1 i
                    !mask = (1 `unsafeShiftL` bits) - 1
                    probe !sl = do
                        y <- VUM.unsafeRead t1 sl
                        if y < 0
                            then do
                                VUM.unsafeWrite t0 sl k0
                                VUM.unsafeWrite t1 sl k1
                                pure True
                            else do
                                x <- VUM.unsafeRead t0 sl
                                if eq x y k0 k1
                                    then pure False
                                    else probe ((sl + 1) .&. mask)
                new <- probe (slotOf pb bits (hashOf k0 k1))
                if not new
                    then go t0 t1 bits cnt (i + 1)
                    else do
                        onNew k0 k1
                        if 2 * (cnt + 1) > mask + 1
                            then do
                                (t0', t1') <- growPairs pb hashOf t0 t1 bits
                                go t0' t1' (bits + 1) (cnt + 1) (i + 1)
                            else go t0 t1 bits (cnt + 1) (i + 1)
    go a0 a1 bits0 0 s
{-# INLINE dedupPairs #-}

growPairs ::
    Int ->
    (Int -> Int -> Int) ->
    VUM.IOVector Int ->
    VUM.IOVector Int ->
    Int ->
    IO (VUM.IOVector Int, VUM.IOVector Int)
growPairs pb hashOf t0 t1 bits = do
    let !bits' = bits + 1
        !mask' = (1 `unsafeShiftL` bits') - 1
    n0 <- VUM.unsafeNew (mask' + 1)
    n1 <- VUM.replicate (mask' + 1) (-1)
    forM_ [0 .. VUM.length t1 - 1] $ \j -> do
        y <- VUM.unsafeRead t1 j
        when (y >= 0) $ do
            x <- VUM.unsafeRead t0 j
            let place !sl = do
                    y' <- VUM.unsafeRead n1 sl
                    if y' < 0
                        then VUM.unsafeWrite n0 sl x >> VUM.unsafeWrite n1 sl y
                        else place ((sl + 1) .&. mask')
            place (slotOf pb bits' (hashOf x y))
    pure (n0, n1)
{-# INLINE growPairs #-}