dataframe-fastcsv-1.0.0.0: src/DataFrame/IO/CSV/Fast.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE CApiFFI #-}
{-# LANGUAGE ForeignFunctionInterface #-}
module DataFrame.IO.CSV.Fast (
fastReadCsv,
readCsvFast,
fastReadTsv,
readTsvFast,
fastReadCsvWithOpts,
fastReadTsvWithOpts,
fastReadCsvWithSchema,
fastReadCsvProj,
readSeparated,
getDelimiterIndices,
CsvParseError (..),
) where
import qualified Data.Vector as Vector
import qualified Data.Vector.Storable as VS
import Data.Vector.Storable.Mutable (
grow,
unsafeFromForeignPtr,
)
import qualified Data.Vector.Storable.Mutable as VSM
import System.IO.MMap (
Mode (WriteCopy),
mmapFileForeignPtr,
)
import Control.Exception (Exception, throwIO)
import Control.Monad (when)
import Foreign (
Ptr,
castForeignPtr,
castPtr,
)
import Foreign.C.Types
import Foreign.Marshal.Alloc (alloca)
import Foreign.Storable (peek, poke)
import qualified Data.ByteString as BS
import Data.ByteString.Internal (ByteString (PS))
import qualified Data.Map as M
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as TextEncoding
import Data.Word (Word8)
import Control.Parallel.Strategies (parList, rpar, using)
import Data.Array.IArray (array, (!))
import Data.Array.Unboxed (UArray)
import Data.Ix (range)
import DataFrame.IO.CSV (
HeaderSpec (..),
RaggedRowPolicy (..),
ReadOptions (..),
TypeSpec (..),
UnclosedQuotePolicy (..),
defaultReadOptions,
schemaTypeMap,
shouldInferFromSample,
typeInferenceSampleSize,
)
import DataFrame.Internal.DataFrame (DataFrame (..))
import DataFrame.Internal.Schema (Schema (..))
import DataFrame.Operations.Typing (
ParseOptions (..),
effectiveSafeRead,
parseFromExamples,
parseWithTypes,
)
readSeparatedDefault :: Word8 -> FilePath -> IO DataFrame
readSeparatedDefault separator =
readSeparated separator defaultReadOptions
fastReadCsv :: FilePath -> IO DataFrame
fastReadCsv = readSeparatedDefault comma
readCsvFast :: FilePath -> IO DataFrame
readCsvFast = fastReadCsv
fastReadTsv :: FilePath -> IO DataFrame
fastReadTsv = readSeparatedDefault tab
readTsvFast :: FilePath -> IO DataFrame
readTsvFast = fastReadTsv
{- | Like 'fastReadCsv' but takes a 'ReadOptions' record. Use this when
you need to tune ragged-row handling, unclosed-quote handling, or the
whitespace-trimming knob; otherwise stick with 'fastReadCsv'.
-}
fastReadCsvWithOpts :: ReadOptions -> FilePath -> IO DataFrame
fastReadCsvWithOpts = readSeparated comma
-- | TSV counterpart to 'fastReadCsvWithOpts'.
fastReadTsvWithOpts :: ReadOptions -> FilePath -> IO DataFrame
fastReadTsvWithOpts = readSeparated tab
{- | Read a CSV and coerce each column to the type declared in the
supplied 'Schema'. Columns mentioned in the schema bypass inference;
columns absent from the schema fall back to the default inference
path. Use when the schema is known (DDL, prior runs) — it's both
faster than inference and guards against the row-1 \"looks like Int\"
misclassification trap.
-}
fastReadCsvWithSchema :: Schema -> FilePath -> IO DataFrame
fastReadCsvWithSchema schema =
readSeparated
comma
defaultReadOptions
{ typeSpec =
SpecifyTypes (M.toList (elements schema)) (typeSpec defaultReadOptions)
}
{- | Read a CSV and return only the named columns, in the order given.
The SIMD scan and delimiter classification still run over the whole
file, but 'extractField' and 'parseFromExamples' are skipped for
unreferenced columns, so the end-to-end cost scales with the size of
the projection rather than the row width.
-}
fastReadCsvProj :: [Text] -> FilePath -> IO DataFrame
fastReadCsvProj projection path = do
df <- fastReadCsv path
pure (projectColumns projection df)
{- | Filter a DataFrame's columns down to (and in the order of) the
supplied names. Missing names are silently dropped rather than
raised; callers that want strict semantics can check the resulting
'columnIndices' map.
-}
projectColumns :: [Text] -> DataFrame -> DataFrame
projectColumns names df =
let idxs = [(n, i) | n <- names, Just i <- [M.lookup n (columnIndices df)]]
newCols = Vector.fromListN (length idxs) [columns df Vector.! i | (_, i) <- idxs]
newIndices = M.fromList (zip (map fst idxs) [0 ..])
(rows, _) = dataframeDimensions df
in DataFrame newCols newIndices (rows, length idxs) M.empty
readSeparated ::
Word8 ->
ReadOptions ->
FilePath ->
IO DataFrame
readSeparated separator opts filePath = do
-- We use write copy mode so that we can append
-- padding to the end of the memory space
(bufferPtr, offset, len) <-
mmapFileForeignPtr
filePath
WriteCopy
Nothing
let mutableFile = unsafeFromForeignPtr bufferPtr offset len
paddedMutableFile <- grow mutableFile 64
paddedCSVFileRaw <- VS.unsafeFreeze paddedMutableFile
let (paddedCSVFile, bomLen) = stripBom paddedCSVFileRaw
contentLen = len - bomLen
indices <-
getDelimiterIndicesPolicy
(fastCsvOnUnclosedQuote opts)
separator
contentLen
paddedCSVFile
let rowEnds = classifyRowEnds paddedCSVFile contentLen indices
totalRows = VS.length rowEnds
if totalRows == 0
then return emptyDataFrame
else do
let headerNumCol = fieldsInRow rowEnds 0
trim = fastCsvTrimUnquoted opts
extractAt =
extractField trim paddedCSVFile indices rowEnds contentLen
(columnNames, dataStartRow, numCol) = case headerSpec opts of
NoHeader ->
( Vector.fromList $
map (Text.pack . show) [0 .. headerNumCol - 1]
, 0
, headerNumCol
)
UseFirstRow ->
( Vector.fromList $
map (extractAt 0) [0 .. headerNumCol - 1]
, 1
, headerNumCol
)
ProvideNames ns ->
(Vector.fromList ns, 0, length ns)
if numCol == 0
then return emptyDataFrame
else do
let dataRows =
collectDataRows
paddedCSVFile
indices
rowEnds
contentLen
dataStartRow
totalRows
numRow = VS.length dataRows
when (fastCsvOnRaggedRow opts == RaiseOnRagged) $
checkNoRaggedRows rowEnds dataRows numCol
let parseTypes name col =
let n =
if shouldInferFromSample (typeSpec opts)
then typeInferenceSampleSize (typeSpec opts)
else 0
mode =
effectiveSafeRead
(safeRead opts)
(safeReadOverrides opts)
name
parseOpts =
ParseOptions
{ missingValues = missingIndicators opts
, sampleSize = n
, parseSafe = mode
, parseSafeOverrides = []
, parseDateFormat = dateFormat opts
}
in parseFromExamples parseOpts col
generateColumn col =
parseTypes (columnNames Vector.! col) $
Vector.generate numRow $ \i ->
extractAt (dataRows VS.! i) col
columns =
Vector.fromListN
numCol
( map generateColumn [0 .. numCol - 1]
`using` parList rpar
)
columnIndices =
M.fromList $
zip (Vector.toList columnNames) [0 ..]
dataframeDimensions = (numRow, numCol)
let rawDf =
DataFrame
columns
columnIndices
dataframeDimensions
M.empty
schemaMap = schemaTypeMap (typeSpec opts)
resolveMode =
effectiveSafeRead
(safeRead opts)
(safeReadOverrides opts)
return $!
if M.null schemaMap
then rawDf
else parseWithTypes resolveMode schemaMap rawDf
{- | An empty 'DataFrame' — returned when the input has no delimiters
(empty file or single line with no separator and no newline). Guards
against a divide-by-zero in the row-stride math when 'numCol == 0'.
-}
emptyDataFrame :: DataFrame
emptyDataFrame = DataFrame Vector.empty M.empty (0, 0) M.empty
{- | Strip a leading UTF-8 BOM (EF BB BF) if present. Returns the trimmed
vector and the number of bytes removed (0 or 3).
-}
{-# INLINE stripBom #-}
stripBom :: VS.Vector Word8 -> (VS.Vector Word8, Int)
stripBom v
| VS.length v >= 3
, VS.unsafeIndex v 0 == 0xEF
, VS.unsafeIndex v 1 == 0xBB
, VS.unsafeIndex v 2 == 0xBF =
(VS.drop 3 v, 3)
| otherwise = (v, 0)
{- | Classify each entry in the flat delimiter vector as either a row
terminator or a field terminator, and return the indices-into-the-vector
of every row terminator. A position counts as a row break if either
* the byte at that position is @\\n@, or
* the position is beyond the original content length, which only
happens for the synthetic end-of-file delimiter written when a file
does not end in a newline.
Field terminators (commas / tabs / the configured separator) are left
implicit: anything between consecutive row terminators is a field.
-}
{-# INLINE classifyRowEnds #-}
classifyRowEnds :: VS.Vector Word8 -> Int -> VS.Vector CSize -> VS.Vector Int
classifyRowEnds file contentLen delimiters =
VS.findIndices isRowBreak delimiters
where
isRowBreak pos =
let p = fromIntegral pos :: Int
in p >= contentLen || VS.unsafeIndex file p == lf
{- | Number of fields that row @r@ contains, derived directly from the
gap between consecutive entries in @rowEnds@.
-}
{-# INLINE fieldsInRow #-}
fieldsInRow :: VS.Vector Int -> Int -> Int
fieldsInRow rowEnds r =
let endIdx = VS.unsafeIndex rowEnds r
startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1
in endIdx - startIdx + 1
{- | Does row @r@ contain exactly one, empty field? That is the case for
a bare @\\n@ or a @\\r\\n@ \u2014 blank lines, which we skip by default to
match pandas / polars @skip_blank_lines@ semantics. We also treat a
row whose only byte is @\\r@ as blank, so CRLF-terminated blank lines
don't leak through when only the @\\n@ counts as a row break.
-}
{-# INLINE isBlankRow #-}
isBlankRow ::
VS.Vector Word8 ->
VS.Vector CSize ->
VS.Vector Int ->
Int ->
Int ->
Bool
isBlankRow file delimiters rowEnds contentLen r =
let endIdx = VS.unsafeIndex rowEnds r
startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1
numFields = endIdx - startIdx + 1
in numFields == 1
&& let fieldEndRaw =
fromIntegral (VS.unsafeIndex delimiters startIdx) :: Int
fieldEnd = min fieldEndRaw contentLen
fieldStart =
if startIdx == 0
then 0
else
fromIntegral
(VS.unsafeIndex delimiters (startIdx - 1))
+ 1
fieldLen = fieldEnd - fieldStart
in fieldLen == 0
|| ( fieldLen == 1
&& VS.unsafeIndex file fieldStart == cr
)
{- | Select the row indices that contain actual data: skip @[0 .. skip - 1]@
and drop any blank rows from the remainder.
-}
{-# INLINE collectDataRows #-}
collectDataRows ::
VS.Vector Word8 ->
VS.Vector CSize ->
VS.Vector Int ->
Int ->
Int ->
Int ->
VS.Vector Int
collectDataRows file delimiters rowEnds contentLen skip total =
VS.filter
(not . isBlankRow file delimiters rowEnds contentLen)
(VS.generate (total - skip) (+ skip))
{- | Extract field @col@ of row @r@ as a 'Text'.
Semantics follow RFC 4180:
* If the raw field is wrapped in @\"..\"@, the outer quotes are
stripped and any embedded @\"\"@ is unescaped to a single @\"@.
Whitespace inside the quotes is preserved verbatim.
* If the raw field is unquoted, it is returned as-is; whitespace
is preserved by default (matching pandas / polars). Callers that
want legacy trim-everything behaviour pass @trimUnquoted = True@.
* A trailing @\\r@ is dropped before decoding so CRLF files produce
clean Text on every column, not only when @Text.strip@ happens to
sweep it up.
If @col@ is out of range (a ragged short row), returns the empty text;
callers convert that into a null via the missing-indicator list that
'parseFromExamples' honours.
-}
{-# INLINE extractField #-}
extractField ::
Bool ->
VS.Vector Word8 ->
VS.Vector CSize ->
VS.Vector Int ->
Int ->
Int ->
Int ->
Text
extractField trimUnquoted file delimiters rowEnds contentLen r col
| col >= numFields = Text.empty
| fieldEnd - fieldStart >= 2
, VS.unsafeIndex file fieldStart == quote
, VS.unsafeIndex file (fieldEnd - 1) == quote =
unescapeDoubledQuotes
. TextEncoding.decodeUtf8Lenient
. unsafeToByteString
$ VS.slice (fieldStart + 1) (fieldEnd - fieldStart - 2) file
| otherwise =
(if trimUnquoted then Text.strip else id)
. TextEncoding.decodeUtf8Lenient
. unsafeToByteString
$ VS.slice fieldStart (fieldEnd - fieldStart) file
where
endIdx = VS.unsafeIndex rowEnds r
startIdx = if r == 0 then 0 else VS.unsafeIndex rowEnds (r - 1) + 1
numFields = endIdx - startIdx + 1
boundaryIdx = startIdx + col
fieldEndRaw =
fromIntegral (VS.unsafeIndex delimiters boundaryIdx) :: Int
fieldEndClamped = min fieldEndRaw contentLen
fieldStart =
if boundaryIdx == 0
then 0
else
fromIntegral
(VS.unsafeIndex delimiters (boundaryIdx - 1))
+ 1
-- Strip a trailing \r (CRLF line endings) from the last field of a row
-- when the SIMD scanner, which only tracks \n, leaves it behind.
fieldEnd =
if fieldEndClamped > fieldStart
&& VS.unsafeIndex file (fieldEndClamped - 1) == cr
then fieldEndClamped - 1
else fieldEndClamped
unsafeToByteString :: VS.Vector Word8 -> BS.ByteString
unsafeToByteString v = PS (castForeignPtr ptr) 0 n
where
(ptr, n) = VS.unsafeToForeignPtr0 v
{- | Walk every data row and throw 'CsvRaggedRow' on the first one whose
field count differs from the header. Only called when the user opts
into 'RaiseOnRagged' via 'fastCsvOnRaggedRow'.
-}
{-# INLINE checkNoRaggedRows #-}
checkNoRaggedRows ::
VS.Vector Int ->
VS.Vector Int ->
Int ->
IO ()
checkNoRaggedRows rowEnds dataRows numCol =
VS.mapM_
( \r ->
let actual = fieldsInRow rowEnds r
in when (actual /= numCol) $
throwIO (CsvRaggedRow r numCol actual)
)
dataRows
{- | RFC 4180 inner-quote unescape: @\"\"@ → @\"@. 'Text.replace' on a
two-char needle does a single linear pass and is allocation-free when
no doubled quote is present (short-circuits at the first miss).
-}
{-# INLINE unescapeDoubledQuotes #-}
unescapeDoubledQuotes :: Text -> Text
unescapeDoubledQuotes t
| Text.isInfixOf doubledQuote t = Text.replace doubledQuote singleQuote t
| otherwise = t
where
doubledQuote = Text.pack "\"\""
singleQuote = Text.singleton '"'
{- | Exceptions raised by the fast CSV parser. Catchable with
'Control.Exception.catch' or 'Control.Exception.try'.
-}
data CsvParseError
= {- | The input ends with a quoted field that was never closed. The
PCLMUL quote-parity chain in the SIMD scanner treats an unmatched
@\"@ as if the rest of the file were inside quotes, so we refuse
to return a silently corrupted 'DataFrame' and raise instead.
-}
CsvUnclosedQuote
| {- | A row has a different number of fields from the header. Only
raised when 'fastCsvOnRaggedRow' is set to 'RaiseOnRagged'.
Carries the 0-based row index, the expected field count, and the
actual field count.
-}
CsvRaggedRow !Int !Int !Int
deriving (Eq, Show)
instance Exception CsvParseError
-- Status codes reported via the out-parameter of 'get_delimiter_indices'.
-- Must stay in sync with @GDI_*@ in @cbits/process_csv.h@.
gdiOk, gdiUnclosedQuote :: CInt
gdiOk = 0
gdiUnclosedQuote = 1
{- | Return value that the C helper uses to tell Haskell \"SIMD isn't
available on this build / CPU, run the pure-Haskell state machine.\"
Mirrors @GDI_SIMD_UNAVAILABLE@ in @cbits/process_csv.h@. 'CSize' is
unsigned so this has the same bit pattern as 'maxBound'.
-}
simdUnavailable :: CSize
simdUnavailable = maxBound
foreign import capi "process_csv.h get_delimiter_indices"
get_delimiter_indices ::
Ptr CUChar -> -- input
CSize -> -- input size
CUChar -> -- separator character
Ptr CSize -> -- result array
Ptr CInt -> -- status out-parameter
IO CSize -- occupancy of result array
{- | Locate delimiter byte positions in @csvFile@. Treats an unclosed
quoted field at EOF as a hard error; callers that want to suppress the
exception can use 'getDelimiterIndicesPolicy' with 'BestEffort'.
-}
{-# INLINE getDelimiterIndices #-}
getDelimiterIndices ::
Word8 ->
Int ->
VS.Vector Word8 ->
IO (VS.Vector CSize)
getDelimiterIndices = getDelimiterIndicesPolicy RaiseOnUnclosedQuote
{-# INLINE getDelimiterIndicesPolicy #-}
getDelimiterIndicesPolicy ::
UnclosedQuotePolicy ->
Word8 ->
Int ->
VS.Vector Word8 ->
IO (VS.Vector CSize)
getDelimiterIndicesPolicy policy separator originalLen csvFile =
VS.unsafeWith csvFile $ \buffer -> do
let paddedLen = VS.length csvFile
-- GC-managed pinned memory: freed automatically, no leak in streaming use.
resultMV <- VSM.unsafeNew paddedLen
(num_fields, status) <- alloca $ \statusPtr -> do
poke statusPtr gdiOk
n <-
VSM.unsafeWith resultMV $ \indicesPtr ->
get_delimiter_indices
(castPtr buffer)
(fromIntegral paddedLen)
(fromIntegral separator)
(castPtr indicesPtr)
statusPtr
s <- peek statusPtr
return (n, s)
if num_fields == simdUnavailable
then do
-- Haskell state-machine fallback, writing directly into resultMV.
let trans = stateTransitionTable separator
processChar (!state, !idx) i byte =
case state of
UnEscaped ->
if byte == lf || byte == separator
then do
VSM.unsafeWrite resultMV idx (fromIntegral i)
return (toEnum (trans ! (fromEnum state, byte)), idx + 1)
else return (toEnum (trans ! (fromEnum state, byte)), idx)
Escaped ->
return (toEnum (trans ! (fromEnum state, byte)), idx)
(finalState, finalIdx) <-
VS.ifoldM' processChar (UnEscaped, 0 :: Int) csvFile
case finalState of
Escaped
| policy == RaiseOnUnclosedQuote ->
throwIO CsvUnclosedQuote
_ -> return ()
finalLen <-
if originalLen > 0 && csvFile VS.! (originalLen - 1) /= lf
then do
VSM.unsafeWrite resultMV finalIdx (fromIntegral originalLen)
return (finalIdx + 1)
else return finalIdx
VS.unsafeFreeze (VSM.slice 0 finalLen resultMV)
else do
when
( status == gdiUnclosedQuote
&& policy == RaiseOnUnclosedQuote
)
(throwIO CsvUnclosedQuote)
let n = fromIntegral num_fields
finalLen <-
if originalLen > 0 && csvFile VS.! (originalLen - 1) /= lf
then do
VSM.write resultMV n (fromIntegral originalLen)
return (n + 1)
else return n
VS.unsafeFreeze (VSM.slice 0 finalLen resultMV)
-- We have a Native version in case the C version
-- cannot be used. For example if neither ARM_NEON
-- nor AVX2 are available
lf, cr, comma, tab, quote :: Word8
lf = 0x0A
cr = 0x0D
comma = 0x2C
tab = 0x09
quote = 0x22
-- We parse using a state machine
data State
= UnEscaped -- non quoted
| Escaped -- quoted
deriving (Enum)
{-# INLINE stateTransitionTable #-}
stateTransitionTable :: Word8 -> UArray (Int, Word8) Int
stateTransitionTable separator = array ((0, 0), (1, 255)) [(i, f i) | i <- range ((0, 0), (1, 255))]
where
f (0, character)
-- Unescaped newline
| character == 0x0A = fromEnum UnEscaped
-- Unescaped separator
| character == separator = fromEnum UnEscaped
-- Unescaped quote
| character == 0x22 = fromEnum Escaped
| otherwise = fromEnum UnEscaped
-- Escaped quote
-- escaped quote in fields are dealt as
-- consecutive quoted sections of a field
-- example: If we have
-- field1, "abc""def""ghi, field3
-- we end up processing abc, def, and ghi
-- as consecutive quoted strings.
f (1, 0x22) = fromEnum UnEscaped
-- Everything else
f (state, _) = state