packages feed

dataframe-core-2.5.0.0: src-internal/DataFrame/Internal/Control/Concurrent.hs

{-# LANGUAGE BangPatterns #-}

-- | Shared concurrency primitives for the dataframe packages.
module DataFrame.Internal.Control.Concurrent (
    -- * Capabilities
    capabilities,
    capabilitiesIO,
    shouldParallelize,
    parThreshold,

    -- * Chunk planning (pure)
    splitChunkRange,
    chunksFor,
    boundsChunks,

    -- * Thread fan-out
    forkJoin,
    forkJoin_,

    -- * Chunked fan-out (per-chunk callbacks only)
    parallelChunks,
    parallelChunks_,
    parallelBounds_,

    -- * Work-stealing pools
    pooledIndices,
    pooledRun,
) where

import Control.Concurrent (forkFinally, getNumCapabilities)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Exception (ErrorCall (..), SomeException, throwIO)
import Control.Monad (when)
import Data.IORef (atomicModifyIORef', newIORef)
import qualified Data.Vector as V
import qualified Data.Vector.Mutable as VM
import qualified Data.Vector.Unboxed as VU
import System.IO.Unsafe (unsafePerformIO)

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

capabilitiesIO :: IO Int
capabilitiesIO = getNumCapabilities
{-# INLINE capabilitiesIO #-}

shouldParallelize :: Int -> Int -> Bool
shouldParallelize threshold n = n >= threshold && capabilities > 1
{-# INLINE shouldParallelize #-}

{- | Row count below which a fan-out does not pay for itself, for the fixed-cost
per-row loops (grouping, the aggregation kernels). Pair it with
'shouldParallelize'. Kernels with a materially different per-row cost —
the join probe, the radix sort — set their own thresholds.
-}
parThreshold :: Int
parThreshold = 200000

splitChunkRange :: Int -> Int -> [(Int, Int)]
splitChunkRange k n
    | n <= 0 = []
    | otherwise =
        [ (lo, lo + len)
        | w <- [0 .. k' - 1]
        , let lo = w * q + min w r
        , let len = q + fromEnum (w < r)
        , len > 0
        ]
  where
    -- A non-positive width is a caller bug; clamp to one chunk rather than
    -- return [] and silently skip the rows.
    !k' = max 1 k
    (!q, !r) = n `quotRem` k'

chunksFor :: Int -> Int -> [(Int, Int)]
chunksFor !threshold !n
    | not (shouldParallelize threshold n) = [(0, n)]
    | otherwise = splitChunkRange capabilities n
{-# INLINE chunksFor #-}

{- | Adjacent pairs of a precomputed bounds vector of length @caps + 1@. Empty
ranges are NOT dropped: the bounds are the caller's and the slot count is
often meaningful. Endpoints are forced here, on the spawning thread, so a
worker never starts by evaluating an index thunk that retains @bs@.
-}
boundsChunks :: Int -> VU.Vector Int -> [(Int, Int)]
boundsChunks caps bs =
    [ (lo, hi)
    | w <- [0 .. caps - 1]
    , let !lo = VU.unsafeIndex bs w
    , let !hi = VU.unsafeIndex bs (w + 1)
    ]
{-# INLINE boundsChunks #-}

rethrow :: Either SomeException a -> IO a
rethrow = either throwIO pure
{-# INLINE rethrow #-}

forkJoin :: [IO a] -> IO [a]
forkJoin [] = pure []
forkJoin [act] = fmap (: []) act
forkJoin actions = do
    vars <- mapM spawn actions
    results <- mapM takeMVar vars
    mapM rethrow results
  where
    spawn act = do
        var <- newEmptyMVar
        _ <- forkFinally act (putMVar var)
        pure var
{-# INLINEABLE forkJoin #-}

forkJoin_ :: [IO ()] -> IO ()
forkJoin_ [] = pure ()
forkJoin_ [act] = act
forkJoin_ actions = do
    vars <- mapM spawn actions
    results <- mapM takeMVar vars
    mapM_ rethrow results
  where
    spawn act = do
        var <- newEmptyMVar
        _ <- forkFinally act (putMVar var)
        pure var
{-# INLINEABLE forkJoin_ #-}

parallelChunks :: Int -> Int -> (Int -> Int -> IO a) -> IO [a]
parallelChunks threshold n body =
    forkJoin [body lo hi | (!lo, !hi) <- chunksFor threshold n]
{-# NOINLINE parallelChunks #-} -- INLINE worsens performance here.

-- | 'parallelChunks' for chunk bodies run only for their effects.
parallelChunks_ :: Int -> Int -> (Int -> Int -> IO ()) -> IO ()
parallelChunks_ threshold n body =
    forkJoin_ [body lo hi | (!lo, !hi) <- chunksFor threshold n]
{-# INLINE parallelChunks_ #-}

parallelBounds_ :: Int -> VU.Vector Int -> (Int -> Int -> IO ()) -> IO ()
parallelBounds_ caps bs body =
    forkJoin_ [body lo hi | (!lo, !hi) <- boundsChunks caps bs]
{-# NOINLINE parallelBounds_ #-}

pooledIndices :: Int -> Int -> (Int -> IO ()) -> IO ()
pooledIndices width count body
    | count <= 0 = pure ()
    | width <= 1 = mapM_ body [0 .. count - 1]
    | otherwise = do
        next <- newIORef 0
        let worker = do
                i <- atomicModifyIORef' next (\j -> (j + 1, j))
                when (i < count) (body i >> worker)
        forkJoin_ (replicate (min width count) worker)
{-# NOINLINE pooledIndices #-}

pooledRun :: Int -> [IO a] -> IO [a]
pooledRun width actions
    | width >= n = forkJoin actions
    | otherwise = do
        next <- newIORef 0
        out <- VM.unsafeNew n
        acts <- VM.unsafeNew n
        sequence_ [VM.unsafeWrite acts i a | (i, a) <- zip [0 ..] actions]
        let worker = do
                i <- atomicModifyIORef' next (\j -> (j + 1, j))
                when (i < n) $ do
                    act <- VM.unsafeRead acts i
                    VM.unsafeWrite acts i consumed
                    r <- act
                    VM.write out i r
                    worker
        forkJoin_ (replicate width worker)
        V.toList <$> V.freeze out
  where
    n = length actions
    consumed = throwIO (ErrorCall "pooledRun: slot already consumed")
{-# INLINEABLE pooledRun #-}