Repository navigation
[CSV all] Upstreamable CSV reader optimizations (combined #16–#24) - #25
karthikeyann wants to merge 35 commits into
Conversation
`extract_date` and `extract_time_of_day`, which the CSV and JSON readers use to parse timestamps, assume that each separator they look for is present. When it is not, `thrust::find` returns `end` and the next search starts at `end + 1`, an inverted range: the search then walks past the field (and possibly past the input buffer) until it happens to find a matching character. Inputs such as `2024T10:00` (no date separator), `2024-01-01T10` (no minutes), `12T10:00`, or a time-only JSON string such as "10:30" trigger this. The parsed values then depend on whatever data follows the field, and near the end of the buffer the read faults (the JSON reader failed with an illegal memory access on "10:30"). The AM/PM suffix check has the same problem for short time-of-day strings: `*(last - 1)` and the whitespace scan read before `begin` for a time part such as `M`, and the resulting `end` can precede `begin`. Add `next_after`, which returns the position after a separator or `end` when the separator was not found, and use it wherever a search or a component starts after a separator that may be missing. Bound the AM/PM checks and the whitespace scan by `begin`. For well-formed input every search finds its separator and the AM/PM suffix is fully inside the field, so the parsed values are unchanged; malformed fields are now parsed from their own characters only (missing components parse as zero, as empty components already did). No performance regression (CSV reader benchmarks on sm_120). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`to_duration` and its helpers `parse_integer` and `parse_optional_integer` test the character at the current position without checking that the position is still inside the field: the sign check, the `+` after "days", the `:` before minutes and seconds, and the `.` before the fraction. Once the parse has consumed the field, each of these reads the character after it. For well-formed durations such as `1 days`, `00:00:01` or `1 days 00:00:01` that is the delimiter, or, for the last field of the input, the byte after the buffer (reported by compute-sanitizer with exact allocations). The value then depends on that byte whenever it happens to be one of the tested characters. Check the position against `end` before each of these reads. Characters inside the field are tested exactly as before, so every duration that does not end at one of these checks parses to the same value. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`serialized_trie_contains` moves to the children of the current node
before matching each character after the first. A node without children
keeps the default `children_offset` of -1, so for a key that continues
past such a node the lookup stepped back to the preceding node and
resumed the search there. When that node, or one of the nodes after it
in the same children list, held the next key character and ended a word,
the lookup reported a match for a key that is not in the trie.
With the default CSV NA values, fields such as `NAA`, `NA/A`, `NAULL`
and `#NAA` were read as nulls. With na_values {"a", "b"}, `bb` and `ba`
were nulls. The JSON reader uses the same lookup for its NA values:
na_values {"1", "2"} turned 21 and 22 into nulls.
A node without children ends every key that reaches it, so a lookup that
still has characters left cannot match; return false instead of
following the negative offset. Lookups of keys in the trie and of keys
that diverge at a node with children are unaffected.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`create_serialized_trie` indexes each node's array of 128 children with the key character as a (signed) `char`. Bytes 0x80-0xFF, i.e. any non-ASCII character such as UTF-8 "é", are negative on the platforms libcudf supports, so building a trie from such a key indexed the array out of bounds: undefined behavior that corrupts memory and, in practice, leaves the key out of the trie. Non-ASCII na_values, true_values and false_values of the CSV reader, and na_values of the JSON reader, did not match (for quoted JSON values, building the trie could crash the process). Index the children by the character's unsigned byte value, over all 256 values. The serializer visits the children in index order, so they are stored in ascending unsigned byte order; make the device lookup compare characters as unsigned bytes too, so that its early exit is consistent with that order (with a signed comparison, "é" would sort before "a" and the lookup would stop at "a"). For ASCII keys both orders agree, so existing tries and lookups are unchanged. The tree used to build the trie now has 256 instead of 128 child pointers per node. It only exists while the trie is built on the host. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A quote character that is never closed extends its field to the end of the input, so the last field of the input can consist of a single quote character. The decode kernel treats a string field as quoted when its first and last characters are quotes; for a one-character field both are the same character, so stripping them produced `end = begin - 1` and a string length of -1 (as `size_t`, a huge length). Building the strings column from it then accessed memory far outside the input (reading "a,b\n1,\"" failed with an illegal memory access). The same happened with detect_whitespace_around_quotes for a field such as ` "`. Require at least two characters before stripping the enclosing quotes; the lone quote is kept as the field's value. `trim_whitespaces_quotes`, used for non-string columns, had the same inverted range for a lone quote, and for an empty field it inspected the characters just outside the field (`*end` and `begin[-1]`, which for the first field of the input is the byte before the buffer). Rewrite the quote stripping to only inspect characters within the trimmed range, so a lone quote yields an empty range. Fields of two or more characters are trimmed exactly as before. This removes the last use of `skip_character`, which is deleted. The empty range is then handled downstream: `parse_numeric` and `is_infinity`, used for integer, floating-point and boolean columns (and shared with the JSON reader), tested the sign of the first character without checking that the range is non-empty. For the lone quote that is the byte after the input. Guard these reads; non-empty ranges parse as before, and an empty field parses as zero. Previously an empty field took its sign from the byte after it, so with the delimiter '-' (and na_filter disabled) an empty floating-point field parsed as -0.0; it now parses as +0.0. Duration columns are covered by the preceding fix to `to_duration`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`read_csv` uses the indexes passed in `use_cols_indexes`, `parse_dates` and `parse_hex` to write into the per-column flags without checking them against the number of columns. A negative index, or one past the last column, wrote out of bounds of the host flags array: heap corruption that could crash the process or go unnoticed. This includes inputs without rows, such as an empty input or a byte range that contains no row start (e.g. a partition of a larger file), where no column is detected and the flags array only covers the user-provided names, if any. A column selection that does not exist is a caller error, and silently dropping it would change the shape of the result, so `use_cols_indexes` now throws `std::out_of_range` for a negative index or an index past the last column (pandas likewise rejects out-of-bounds `usecols`). The columns of an input are named by its header or first row. An input without rows has nothing to read, and its columns are those named by the user: - As many names as selected columns name the selected columns (had they named all columns, the selection would be all columns as well). The result has these columns, in the order of the names, without rows: the same column names as for an input with rows. Validating the indexes against the names instead would make the result depend on which indexes happen to be below the number of names, and could give a column the name meant for another one. - Other names name all columns, and the indexes are validated against them: an index at or past the number of names throws. - Without names no column is known: the result has no columns, as for such inputs without a column selection. `parse_dates` and `parse_hex` only modify how columns are parsed; their names are already ignored when they match no column, so indexes that match no column are ignored the same way. Document the behavior on the options and on `read_csv`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With `doublequote` enabled, the CSV reader turned escaped quote pairs
("" -> ") into single quotes after decoding: the decode kernel recorded
a per-row "was quoted" flag for every string column, and the host then
ran, per string column, a thrust::count over the flags, a
cudf::strings::replace over the whole column, and a copy_if_else to keep
the unquoted rows unreplaced. The columns were spread over a host worker
pool and forked streams. For string-heavy files this pipeline built every
string column up to three times and was a large share of the read time.
The decode kernel now collapses the pairs itself, in place, in the device
copy of the input that the reader owns, and the string (pointer, length)
pairs describe the unescaped content. The string columns are then built
once by the regular column_buffer path. The is_quoted arrays, the
per-column post-processing, the worker pool use and the stream forking
are removed.
Correctness:
- Pairs are matched left to right without overlap, which is exactly the
result of the previous replace of "" with " (`""""` -> `""`), and only
in fields that had their enclosing quotes stripped, as before; fields
without a pair are not written.
- The in-place update is race-free: each row is decoded by one thread, a
field's bytes belong to one row, and the thread locates the end of the
field (and so the start of the next one) before compacting it. The
compaction never writes ahead of the read position.
- The reader always decodes its own device copy of the input, so the
caller's buffers are never modified. The unescaped data only has to
live until the string columns are built, which copy the characters.
The kernel documentation now states the invariants the in-place rewrite
relies on: all accesses for a row stay within its byte range, row ranges
do not overlap, and decoding is the last use of the data before the
string columns are built. Later changes that load bytes outside a row
(e.g. word loads) must not use them.
Tests cover pairs at the start/end of the content, adjacent pairs, pairs
before a delimiter or around a newline, a pair before the closing quote
with CRLF line ends, pairs in unquoted fields, empty quoted fields, a
custom quote character, detect_whitespace_around_quotes, doublequote
disabled, quoting NONE, inferred string columns, 10k rows of mixed
quoted and unquoted fields, byte ranges (concatenated ranges equal the
full read), and a device source that must be left unchanged.
Measured on an RTX PRO 6000 (nvbench, 256 MB, GPU time, minimum of two
interleaved rounds against the parent):
input config before after
device buffer QUOTE_25_PCT 17.1 ms 8.6 ms (-50%)
device buffer QUOTE_100_PCT 51.2 ms 10.9 ms (-79%)
device buffer TAXI 10.6 ms 8.6 ms (-19%)
device buffer LOGS 9.2 ms 5.2 ms (-44%)
host buffer QUOTE_25_PCT 48.5 ms 37.4 ms (-23%)
host buffer QUOTE_100_PCT 53.9 ms 26.5 ms (-51%)
String-only reads gain the most (all-string, 64 columns, no inference:
49.4 -> 10.5 ms); reads without string columns are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
decode_data created every output column with a zeroed data buffer: a memset of num_rows * sizeof(T) for each fixed-width column, ahead of a decode kernel that writes nearly all of it. The kernel writes a fixed-width slot exactly when the field is valid (ConvertFunctor stores the value only when it returns true), and that is exactly where it sets the validity bit. Every other slot is under a null bit, so its contents are unspecified per the cudf column contract. The fixed-width data buffers are therefore created without zeroing; the null masks are still zero-initialized. String (pointer, length) pairs stay zero-initialized. The kernel writes them for valid and NA fields, but not for fields missing from the end of a short row, which read as null through the zeroed pair. Writing those entries in the kernel instead, and dropping this memset too, was considered and rejected: - Handling the missing fields in or after the per-row field loop raised register pressure enough to spill the loop's state to local memory; an all-integer read (8 columns, 256 MB, no inference) went from 8.6 ms to 47 ms. - Setting every string pair of the row to null before the field loop avoided the spill, but stores each pair twice for valid fields and loops over all columns for every row; it was no faster than the memset on string-heavy reads (within noise, trending slower on 64 string columns). Zeroing only the pair arrays is the simplest form that keeps the win. A new test reads 1 to 1000 rows whose field counts cycle from 5 down to 1 across INT32, STRING, FLOAT64 and BOOL8 columns, with NA fields, plus use_cols and delim_whitespace variants. It fails if the missing string fields are left unwritten and uninitialized. This removes one num_rows * sizeof(T) memset per fixed-width column. On an RTX PRO 6000 (nvbench, 256 MB, device buffer, shared GPU so approximate), an 8-column all-integer read without inference went from 8.6-8.7 to 8.4 ms (about 3%); string columns do the same work as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CSV reader loads and parses its input in 64MB chunks so that it can stop early once a byte range or the requested number of rows has been covered. When the whole file is read, which is the common case, the chunking only costs time: every chunk synchronizes the stream to bring the block row contexts of the first gathering phase to the host, and the row offsets vector is regrown and copied for every chunk. Parse the whole file as a single chunk when no row selection option is set. Chunked parsing is unchanged for byte ranges, skiprows, nrows and skipfooter. The input used to be read with one `host_read` per chunk for sources that do not prefer device reads, so a single chunk would have put a copy of the whole file in host memory for sources that allocate on reads. Read such sources in chunks of at most 32MB instead, each copied to the device before it is released: at most two chunks are held at a time, the one being copied and the next one, whose read is requested with host_read_async before the copy so that it runs on the host worker pool meanwhile. If the copy throws, the pending read is waited for before the exception propagates, since it uses the source. Sources that prefer device reads are read into device memory as before. Staging the chunks through pinned buffers instead was measured to be slower for pageable host buffers (47ms vs 41ms to copy 528MB on the test machine): the driver's pageable copy path is faster than a single-threaded memcpy into pinned memory. Also move the per-block row context merge tree of gather_row_offsets from a global memory scratch buffer (8KB per 16KB of input, e.g. 128MB for a 256MB file) into shared memory. Measured end to end with the CSV reader benchmarks on 256MB inputs (RTX PRO 6000 Blackwell, minimum of 4 interleaved runs): reads from files, now one device read instead of one per chunk, take 16% less time for the TAXI profile and 13% less for 25% quoted fields; device buffer reads change by -6% to +3% and pinned host buffers by -1% to -2%. Pageable host buffers are flat (+1% and +2%, within noise): their reads are bound by the host-to-device copy. Add tests that read a 45MB input from a user source, checking that no read exceeds 32MB, and from pageable and pinned host buffers, and that a failed or short read from the source is reported. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
gather_row_offsets computes, for each 32-character slice of the input, the row start bitmap and output parser state for every possible input state, by merging the transitions of the 32 characters one at a time. Most slices hold no quote or comment character, and in those only the characters that follow a terminator matter. When a slice is fully in range and 16-byte aligned, load it with two 16-byte loads and locate its terminator, quote and comment characters with SWAR byte comparisons, then: - If the slice holds no quote or comment character, write the result directly: every character after a terminator starts a row in the NONE and COMMENT (pending quote exit) states and in none in the QUOTE state; NONE and COMMENT end in NONE (COMMENT exits at the first character) and QUOTE stays QUOTE. This is what the per-character merge computes, since each character is then either a row start that is neither a quote nor a comment character, or a regular character. - Otherwise, merge only the characters that follow a terminator and the quote characters with their full transitions. Every other character has the regular transition (NONE->NONE, QUOTE->QUOTE, COMMENT->NONE, no row start), which is idempotent, so each run of them is merged once. Both paths produce exactly the result of the per-character loop, which still handles unaligned slices and slices that cross the end of the chunk. The per-character transition is factored into char_row_context, and the characters it depends on are passed as one struct (dropping the unused escape character argument). The slices are aligned for whole-file reads and for the first chunk of nrows and skipfooter reads. Byte range reads, and skiprows reads after their first chunk, keep one character before the data they parse, so their slices are one byte off alignment and take the per-character loop as before; their results are unaffected. Aligning them is left for a follow-up. On 256MB inputs, gather_row_offsets_gpu takes 38% to 45% less time per read (nsys, both phases): 1490us -> 850us for the TAXI profile of the realistic CSV reader benchmark, and 1803us -> 997us, 1072us -> 614us and 684us -> 423us for 0%, 25% and 100% quoted fields. End to end, reads from device memory take 3% to 12% less time (minimum of 4 interleaved runs, RTX PRO 6000 Blackwell): TAXI 7.82ms -> 7.32ms, LOGS 4.52ms -> 4.15ms, ANALYTICS 7.80ms -> 7.03ms, and 11.38ms -> 10.04ms, 7.96ms -> 7.38ms and 9.77ms -> 9.50ms for 0%, 25% and 100% quoted fields. Add tests that sweep quoted fields with terminators, delimiters and escaped quotes, and a comment line, across 16KB block boundaries for '\n', CR-only and non-ASCII terminators, that check an unterminated quote at the end of the data, byte ranges against a full read, row selection with a quoted field across the 64MB chunk boundary, and a comment character at the end of an aligned slice (which the quote and comment free shortcut must exclude). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Row offsets are gathered in two phases: the first counts the rows of each 16KB block for every possible parser state at its start, the host then resolves the actual state of each block and the row counts, and the second phase writes the offsets. For a whole file this costs a device-to-host copy of the block contexts, a host loop over all blocks, a host-to-device copy back and a second read of the data. Parsing the whole file needs no row selection, so gather its rows in a single pass over the data with no host round trip: 1. A speculative pass processes 32KB tiles in parallel, each assuming that it starts in the NONE state (outside of quotes). For each tile it writes the row start bitmap of every 32-character slice and the number of rows, and records the tile's state transition function (the output state for each input state, packed in a byte). Within a tile, a block scan over the threads' transitions gives each thread its input state. 2. An exclusive scan over the tile transitions, composed as functions, gives the exact start state of every tile. This does not depend on the speculation, since the transitions cover every input state. 3. A repair pass processes again, with one block per tile, the tiles whose start state is not NONE: those where a quoted field spans or ends at the start of the tile. Other blocks exit at once. 4. An exclusive scan of the tile row counts gives the first row of every tile and the total, which is copied to the host (the only synchronization) to allocate the offsets, and a last pass converts the bitmaps to offsets. The result is identical to the two-phase gathering: the bitmaps of each tile are those selected by its exact start state, from the repair pass if the speculation was wrong. Each tile is processed at most twice, so even when the speculation fails for every tile (e.g. a quoted field that spans the whole file) the work is at most twice that of one pass and stays linear in the input size. The bitmaps take one bit per input character. The fraction of repaired tiles follows the fraction of quoted characters in the data. On the 256MB CSV reader benchmark inputs it is 0% for the ANALYTICS profile and unquoted fields, 2% for TAXI, 14% for LOGS, and 11% and 26% for 25% and 100% quoted fields; with 100% quoted fields the repair pass takes 52us next to 229us for the speculative pass. Chunked gathering is unchanged for row selection (byte range, skiprows, nrows, skipfooter), which can stop early. On 256MB inputs, the row gathering kernels (including the removal of blank rows) take 25% to 31% less time per read (nsys): 1046us -> 774us for TAXI, and 1085us -> 749us, 645us -> 471us and 440us -> 328us for 0%, 25% and 100% quoted fields. End to end, reads from device memory take up to 8% less time (minimum of 4 interleaved runs, RTX PRO 6000 Blackwell): TAXI 7.32ms -> 6.77ms, LOGS 4.15ms -> 3.91ms, ANALYTICS 7.03ms -> 6.61ms, and 7.38ms -> 7.23ms and 9.50ms -> 9.27ms for 25% and 100% quoted fields; unquoted fields are flat (10.04ms -> 10.17ms). Add tests where every tile but the first starts inside a quoted field (with more tiles than the device runs blocks at once), where a quoted field spans many tiles, where the characters of short probes are placed in turn at tile starts so that tiles start in each parser state, and a seeded test that compares whole-file and chunked reads of random multi-tile documents with random quoting, comment and blank line options. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After gathering the row offsets of a whole file, the reader removes the offsets of blank and comment rows with a thrust::remove_if over all offsets, which reads every offset, the first character of every row, and writes the offsets again. The single-pass gathering already visits every row start while it selects the row start bitmaps, with the character in cache. Clear the bits of the rows that remove_blank_rows would remove there, before the rows of each tile are counted: the exclusive scan of the counts then gives the positions of the remaining rows, and the offsets of blank rows are never written. The row that marks the end of the data is always kept, as in remove_blank_rows. The blank row predicate is shared by count_blank_rows, remove_blank_rows and the gathering, so that the chunked and the single-pass paths skip the same rows. On 256MB inputs this removes the remove_if (217us per read for the TAXI profile of the realistic CSV reader benchmark, and 99us, 38us and 17us for 0%, 25% and 100% quoted fields, nsys) while the gathering kernels take the same time within 1us. End to end, reads from device memory take up to 7% less time (minimum of 4 interleaved runs, RTX PRO 6000 Blackwell): TAXI 6.77ms -> 6.53ms, LOGS 3.91ms -> 3.63ms, 0% quoted fields 10.17ms -> 9.53ms; the others are flat. Add a test with blank, CRLF blank and comment lines, alone and in runs that span many tiles, at the start and at the end of the data, with the '\n' and a custom terminator, and with skip_blank_lines disabled, comparing the whole-file read with the chunked row selection path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The type detection and decode kernels process one row per thread, and
`seek_field_end` reads each row one character at a time. The loads of a
warp are therefore scattered over 32 rows, and each character costs a
separate memory transaction.
Add `seek_field_end_by_words`, which returns the same position as
`seek_field_end` (without escape characters) but loads the row in
aligned 8-byte words, and use it in both kernels:
- In a field that does not start with a quote, only a delimiter, a
terminator or a '\r' can end the field. The bytes of the word that
may hold one of them are flagged with the SWAR zero-byte test on the
word XOR the broadcast character. That test flags every occurrence
(it may also flag other bytes), and the flags are checked in
increasing order with the exact test of `seek_field_end` ('\r' only
ends a field before '\n'), so the first flag that passes is the
position `seek_field_end` returns, and false flags fail the test.
- The characters of a quoted field are run through the same quote
state machine as in `seek_field_end`, from the loaded word.
- Multi-character delimiters (delim_whitespace) keep using
`seek_field_end`.
Only words that lie entirely within [data begin, row end) are loaded,
whatever the alignment of the data, so no memory outside of the data
is read even for unaligned or unpadded buffers; the characters of the
row outside of such words are read one at a time. (The reader parses
its own aligned copy of the data for now, so the lower bound matters
for sources that would be parsed in place.) A word may start in
the previous row, whose bytes the decode kernel may be rewriting (in
place unescaping); those bytes are never used, as the documented
invariant of the decode kernel requires.
Median kernel times (nsys, RTX PRO 6000, 256MB benchmarks):
benchmark kernel before after
REALISTIC TAXI decode 5.64 ms 3.90 ms
REALISTIC LOGS decode 1.62 ms 1.25 ms
REALISTIC ANALYTICS decode 5.57 ms 4.12 ms
QUOTING QUOTE_0_PCT decode 9.38 ms 6.53 ms
QUOTING QUOTE_25_PCT decode 4.72 ms 3.02 ms
QUOTING QUOTE_100_PCT decode 1.91 ms 1.22 ms
INFERENCE ALL_INTEGRAL, 64 detection 11.36 ms 8.89 ms
INFERENCE ALL_FLOAT, 8 detection 6.61 ms 5.38 ms
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`batch_count_set_bits`, which backs `cudf::batch_null_count`, indexes the bitmasks with the second dimension of the grid, whose size is limited to 65535, so counting more bitmasks failed with cudaErrorInvalidValue. Launch the kernel once per batch of at most 65535 bitmasks. Nothing changes for fewer bitmasks. The CSV reader is about to count the nulls of its columns with this function, and CSV files can have more columns than that. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The decode kernel set the validity bit of each valid field with its own atomicOr, and counted the valid fields of each column with an atomicAdd per field. The 32 lanes of a warp decode 32 consecutive rows that start at a multiple of 32, so their bits of a column all lie in the same mask word and their atomics serialize on it; the counter of a column is a single address that every warp of the grid updates for every field. Group the lanes that mark the same column with __match_any_sync and let the lowest of them set all of their bits with one atomicOr (the bit of a row is its lane index). The lanes that are active together normally mark the same column, but that is not guaranteed after divergence, so they are grouped by column. The atomic is still required because different groups of the same warp can update the word at different times. Stop counting in the kernel: after decoding, count the nulls of the non-string columns from their masks with cudf::batch_null_count. This is exact because the masks are zero-initialized, bits are only set for the valid fields of rows in [0, num_records), and exactly that range is counted. String columns get their null counts when they are built from the decoded string pairs, as before, so their masks are not counted. The count replaces the copy of the kernel's counters to the host, so the number of synchronizations does not change. A test reads a table with more columns than the batched count can index in one grid (65535), which the previous commit enables. Median time of the decode kernel (nsys, RTX PRO 6000, 256MB benchmarks): benchmark before after INFERENCE ALL_INTEGRAL, 8 4.24 ms 2.26 ms INFERENCE ALL_INTEGRAL, 64 7.51 ms 4.36 ms INFERENCE MIXED, 8 3.83 ms 2.26 ms INFERENCE MIXED, 64 3.72 ms 2.97 ms QUOTING QUOTE_0_PCT 6.53 ms 4.33 ms QUOTING QUOTE_25_PCT 3.02 ms 2.31 ms REALISTIC TAXI 3.90 ms 3.39 ms REALISTIC LOGS 1.25 ms 1.10 ms Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The type detection kernel incremented a member of the column's column_type_histogram with a global atomicAdd in each branch of its classification. The counters of a column are a few addresses that every thread of the grid updates for each row, so these atomics serialize. Classify each field into a `field_class`, which indexes an array of `num_field_classes` counters per column, and increment the counter in one place. The lanes of a warp that increment the same counter at the same time are grouped with __match_any_sync, and the lowest of them adds their number with a single atomic. The counts are unchanged. Integers are classified by the new classify_integral_field, which holds the logic of infer_integral_field_counter; the latter is now a thin wrapper around it for the JSON type inference. The histograms are assembled on the host from the counters, so the device code no longer depends on the layout of column_type_histogram. A new test decides column types with single fields in later blocks of rows and with the number of NA fields, so that a lost or miscounted increment changes an inferred type. Median time of the type detection kernel (nsys, RTX PRO 6000, 256MB CSV_READER_TYPE_INFERENCE_NVBENCH with inference): profile, columns before after ALL_INTEGRAL, 8 2.77 ms 1.85 ms ALL_INTEGRAL, 64 8.00 ms 5.67 ms MIXED, 64 11.48 ms 5.92 ms ALL_STRING, 8 8.68 ms 1.15 ms ALL_STRING, 64 6.08 ms 2.03 ms ALL_FLOAT, 8 5.38 ms 5.73 ms (see the next commit) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Narrow tables have few type detection counters, and every warp of the grid updates them, so even warp-aggregated global atomics contend on them. This is worst when the lanes of a warp reach their increments at different times, as with variable-length floating point fields. For tables of up to 32 inferred columns, each block counts in shared memory, zeroed on entry, and adds its non-zero counts to the global counters at the end; threads without a row now stay until then instead of returning early. The counts are exact either way. The kernel is a template on where the counters are, rather than taking a runtime flag. With the counters known to be in shared memory, the compiler emits shared memory atomics (ATOMS in the SASS of this version); in a build that selected the counters with a runtime flag, the increment compiled to a generic atomic (ATOM.E.ADD), since the counter pointer can be in either address space. The global memory instance also keeps its early return and has no block barriers. The threshold only affects speed, and was measured on one GPU (RTX PRO 6000 Blackwell). Larger shared memory allocations per block made the kernel slower than counting in global memory (64 columns: 8.9 ms instead of 5.7 ms; 32 columns with the allocation padded to 2KB: 8.3 ms instead of 3.6 ms), likely because the kernel, which reads rows through the L1 cache, then gets a larger shared memory carveout. Wider tables spread their updates over enough counters that global memory atomics do not contend much on them. Median time of the type detection kernel (nsys, 256MB CSV_READER_TYPE_INFERENCE_NVBENCH with inference, num_cols overridden for 16 and 32): profile, columns global shared ALL_FLOAT, 8 5.6 ms 2.8 ms ALL_FLOAT, 32 4.9 ms 3.5 ms ALL_INTEGRAL, 16 6.4 ms 3.3 ms ALL_INTEGRAL, 32 5.5 ms 3.6 ms MIXED, 16 4.9 ms 3.2 ms ALL_INTEGRAL, 8 1.76 ms 1.88 ms Tables with 8 integer columns get slightly slower (ALL_INTEGRAL and MIXED: 2-3% end to end), which the gains above outweigh. The tests that decide column types with single fields in later blocks now also cover 32 and 33 columns, on both sides of the threshold, and fail if a block's shared counts are not added to the global counters. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`load_data_and_gather_row_offsets` removed the header rows and applied the nrows limit, and its caller then applied the skipfooter limit. The limits are now all applied in one place, `load_data_and_gather_row_offsets`, before the header offsets are copied to the host. The data and its selected row offsets are returned in a named struct instead of a pair. This prepares for work on the rows to read that has to be done on the device before that copy, so that its result can be copied to the host in the same synchronization (the next commit needs this). The behavior is unchanged: the header rows are removed only if the header row exists, as before, the nrows and then the skipfooter limits are applied in the same order to the same offsets, and the header is read from the same offsets. `skipfooter` cannot be combined with `nrows` or with a byte range (both are rejected earlier), so the only interactions are with the header rows, skiprows and blank rows. A differential run over 20,736 reads with header, skiprows, skipfooter, nrows, blank rows and byte ranges gives byte-identical results and exception messages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The decode kernel parses one row per thread, so the 32 threads of a warp read 32 different rows: each character (or 8-byte word) load of the warp is up to 32 separate memory transactions, and the sectors it fetches are reused only while they stay in the L1 cache. Each block can now first copy its rows, which are contiguous in the data, into dynamic shared memory with coalesced 16-byte loads, and its threads then parse the copy. The copy keeps the characters' address modulo 16, so the aligned 16-byte words within the rows are copied as a whole and the characters of the rows outside of such words one at a time, whatever the alignment of the data. Only the characters of the block's rows are read. Strings still point into the reader-owned data, never into shared memory: a quoted field is unescaped from the staged copy into `data`, whose bytes of the field are still those of the copy, since a block stages its rows before any of its threads rewrites a field and no other block touches them. `unescape_doublequotes` therefore takes a source and a destination that holds a copy of it (or is the source itself). Every read of a field stays within the field, so no staged read touches the buffer before the first row or after the last one: the word loads of `seek_field_end_by_words` are bounded by the staged characters, and `trim_whitespaces_quotes` does not read before the field. Staging size: the shared memory needed to stage the rows of every block is computed on the device, over the rows to read (after the header rows and the nrows/skipfooter limits are applied, see the previous commit), and copied to the host in the synchronization that already fetched the header offsets. The blocks of the kernels must be those of the computation: removing the header rows shifts the blocks, and a size computed before that can be too small. Each staged block checks that its rows fit in the launch's buffer, and the kernel stops with an error otherwise (one comparison per block). It cannot fall back to parsing global memory without losing the staged instance's shared memory loads (see below). `block_row_range` is the single definition of the rows of a block. When to stage (`is_row_staging_used`): - The size must fit in the dynamic shared memory that the kernel can use without opting in to more (48KB on all GPUs, less the kernel's static shared memory, from cudaFuncGetAttributes). - Staging must not reduce the occupancy of the launch (resident blocks per SM, from the occupancy API for the current GPU and kernel). On an RTX PRO 6000 (sm_120, 100KB of shared memory per SM, 7 resident unstaged blocks per SM), staging was faster at equal occupancy in every case measured, while with fewer blocks per SM the outcome depended on the data: rows of 134 to 174 characters decoded 14% to 30% faster with 5 or 4 staged blocks per SM, but TAXI was 17% slower with 3. So staging is only used where it costs no occupancy. The rule has no tuned constant, but it was only measured on sm_120: on GPUs with more shared memory per SM (sm_80: 164KB, sm_90: 228KB) it stages larger blocks (up to about 22KB and 31KB), e.g. TAXI's 28KB blocks on sm_90, which have not been measured. It also depends on the register counts of the two instances (56 staged against 72 unstaged on sm_120), which compiler or code changes can move. Before merging, LOGS, TAXI, ANALYTICS and the 8-column type inference benchmarks should be measured on an sm_80 and an sm_90 GPU; a loss there would call for a condition on the rule, not a tuned threshold. - The rows of all blocks are staged or none, by separate kernel instances. The shared memory size of a launch applies to all of its blocks, so the blocks that could not be staged would run at the lower occupancy of the staged launch (1.41 ms instead of 1.10 ms for rows of about 400 characters). And in a single kernel whose rows could be in either memory, every character load compiles to a generic load, which made the unstaged decode twice slower (QUOTE_0_PCT: 4.3 ms -> 8.4 ms). - The `LIBCUDF_CSV_ROW_STAGING` environment variable (AUTO by default, ALWAYS or NEVER) overrides the occupancy condition. Like LIBCUDF_NVCOMP_POLICY and LIBCUDF_HOST_COMPRESSION, it is read on every call, so tests can set it: the staging tests run both parsing paths whatever the GPU, and staging never changes the results. Measured on an RTX PRO 6000 (256MB inputs; decode kernel time, minimum of 20+ runs under nsys): LOGS 1.09 ms -> 0.87 ms (staged, 13.3KB per block, 7 blocks per SM as without staging), rows of 94 characters with 8 integer, 4 float and one string column 1.26 ms -> 0.96 ms. TAXI, ANALYTICS and QUOTE_0/25/100_PCT are not staged and unchanged. End to end (nvbench GPU time, minimum of 2 rounds on a shared GPU), LOGS 3.63 ms -> 3.43 ms; the other benchmarks are within noise. The size computation adds a reduction of 4 to 7 us. Tests (each run with staging forced and disabled): blocks of rows at the staging limit, starting at the last position of a 16-byte word, with a multiple of 16 characters, and one character over the limit; rows after a header whose first block is longer than any block that includes the header; long rows (1,000 rows of 530 characters, and a 60KB row among short rows); rows starting at every position of a 16-byte word; empty first and last fields of rows staged at the very start of the buffer, or parsed in global memory, where compute-sanitizer with exact allocations detects reads outside of the rows; an invalid staging policy. Computing the size before the header rows are removed, or without the 15 bytes before the rows, fails these tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Type detection parses one row per thread like the decode kernel, and has the same scattered loads. It now stages the rows of its blocks in shared memory under the same conditions as the decode kernel (see `is_row_staging_used`), using the staging size that the reader already computes for the decode, whose blocks are the same. The per-block counters of narrow tables (up to 32 inferred columns) stay at the start of the dynamic shared memory, and the staged rows follow them. Their size, 32 bytes per column, keeps the staged rows 16-byte aligned (checked by a static_assert). Both staging conditions account for the counters: the counters and the staged rows together must fit in the dynamic shared memory the kernel can use without opting in (48KB less its static shared memory), and the occupancy of the staged launch, with both, is compared with that of the unstaged launch, with the counters only. The staged and unstaged launches are separate instances of the kernel, as for the decode kernel. Measured on an RTX PRO 6000 (sm_120; 256MB inputs, 8 columns; detection kernel time, minimum of 20+ runs under nsys): - ALL_INTEGRAL: 1.96 ms -> 1.59 ms; MIXED: 1.97 ms -> 1.58 ms (10KB per block, 9 staged blocks per SM against 7 without staging). End to end (nvbench GPU time, minimum of 2 rounds on a shared GPU), with the previous commit: 6.38 ms -> 5.33 ms and 6.36 ms -> 5.23 ms. - ALL_FLOAT and ALL_STRING (17KB and 19KB per block, 5 and 4 staged blocks per SM) are not staged and unchanged. Staged anyway, they took 1.77 ms instead of 2.61 ms and 1.35 ms instead of 1.17 ms: with lower occupancy staging can be slower, which the occupancy condition rules out. As for the decode kernel, the rule was only measured on sm_120. Test: type inference of rows with fields of 1 to 3 characters, with the counts in shared memory (1 to 32 columns) and in global memory (33 and 36 columns), with staging forced and disabled. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`classify_integral_field` skips the leading zeros of integers of 19 digits or more before comparing them with the 64-bit limits. The loop dereferenced the next character before checking that it is still in the field, so an integer made of zeros only read the character after it. This is a pre-existing bug in upstream libcudf, in code that the CSV and JSON type inference share. The CSV reader's type inference reads one byte past the data when such a field ends the input without a final terminator: past the reader's copy of the input, or, for a staged block, past its rows in shared memory. In JSON, values are always followed by a structural character, so the read stays within the input there. It is fixed in this series because parsing device buffers in place (later in this series) makes that byte the one past the end of the caller's buffer; it was found while auditing every read of the CSV kernels against exact-size caller buffers. Check the bound first. The classification does not change: the byte past the digits only ended the loop, which also ends at the bound. Test: inferred reads of 19 to 32 zeros at the end of the data, with rows staged in shared memory and not. The test passes without the fix: it only detects the read under compute-sanitizer memcheck with exact allocations (--rmm_mode=cuda), when the rows are not staged: 21 invalid reads without the fix, none with it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`datasource::device_read(offset, size, stream)` returns a buffer that may own a copy of the data, as for kvikIO file sources, or be a view of device memory that holds the data already, as for device buffer sources. Readers cannot tell the two apart, so they copy the data of all sources into memory of their own, even when it is already in device memory. Add `datasource::supports_zero_copy_device_read()`, false by default. A source that returns true (and supports device reads) guarantees that the buffer returned by `device_read(offset, size, stream)` points to device memory that holds the data in stream order on `stream`, and that stays valid and unmodified until the buffer is destroyed and the reader's work on `stream` is complete. Readers can then process the data in place, without modifying it, whether or not the source prefers device reads, since no read takes place. It does not apply to reads into a preallocated buffer, which always copy. The memory must be device memory, not host memory that the device can access (pinned, or managed memory resident on the host): readers may read the data several times, and such sources should let them copy it once instead. The doc of `device_read(offset, size, stream)` now also allows calling it when the source supports zero-copy device reads. Device buffer sources return true; `datasource::create(device_span)` now documents that readers may process the buffer in place, and that it must hold the data when a read starts and must not be modified or freed until the read completes (host reads of the source are not ordered on the stream of the read). The wrapper of user-implemented sources forwards the query, so that user sources that hold their data in device memory can opt in. The name follows `supports_device_read()`, and Arrow's `RandomAccessFile::supports_zero_copy()`: it is a capability of the source, not a per-size preference like `is_device_read_preferred()`. Adding a virtual function changes the vtable of `datasource`, so subclasses outside of libcudf must be rebuilt. Test: the query for device buffer, host buffer, file and user sources, and device reads of a device buffer and of user sources through the wrapper, which return views exactly for zero-copy sources, in a new DATASOURCE_TEST. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CSV reader copies its whole input into device memory it owns before parsing it, because decoding unescapes quoted strings (`""` -> `"`) in place. For device buffer sources this is a device-to-device copy of the whole input (0.75 ms for the 554MB input of the TAXI benchmark), and device memory of the size of the input for the whole read. Whole-file reads (no byte range, skiprows, nrows or skipfooter, and no compression) of sources whose device reads return views of their data (`datasource::supports_zero_copy_device_read`, true for device buffers) now parse the view in place, whether or not the source prefers device reads, since a view involves no read. Reads with row selection still copy the chunks they read, and host, pinned and file sources are read as before: kvikIO file sources read into the reader's buffer, which is already a single read with no extra copy, and they keep unescaping in place. Empty ranges (a BOM-only input) are not requested from the source. The caller's buffer is never written: `decode_row_column_data` takes the data as read-only, and an `unescape_buffer` at the same offsets that receives the unescaped quoted fields. It is the data itself when the reader owns it (in place, as before), and otherwise uninitialized scratch memory of the size of the data, allocated when decoding starts and only if `doublequote` is set and a column is a string column. `unescape_doublequotes` now takes whether the output already holds the content: in place, from the data or from its staged copy, the characters before the first pair are not rewritten; into scratch memory, the whole unescaped content is written. Fields without escaped pairs are not written, and their strings keep pointing into the source's data. No kernel reads outside of the data, so the view can be exactly the caller's buffer, at any alignment: - The single-pass row gathering loads 16-byte words only for 32-byte slices that are aligned and lie entirely within the data, and one character at a time (bounded by the end) otherwise; the character before a slice is read only if the slice is not at the start. The blank row test reads only offsets below the size. - Row staging copies exactly the rows of a block, word loads of the field seek are bounded by the data start and the row end, and field parsing stays within the field (with the first commit's fix). Unaligned views (a UTF-8 BOM makes the data start 3 bytes into the buffer, and subspans of buffers can start anywhere) are parsed in place too, but the row gathering then loads every slice one character at a time: 783 us instead of 403 us for 512 MiB of data, which still costs less than the copy (681 us) plus the gathering of the aligned copy (438 us). Aligning the slices to absolute addresses would give them the word loads; that is left for a follow-up. Memory: the scratch memory replaces the copy, is only allocated for decoding and only when string columns can need it, so the peak device memory of a read is never larger than before. Measured on an RTX PRO 6000 (nvbench GPU time, 256MB configurations of the IO benchmarks, interleaved runs; the device is shared with other jobs, so gains vary by a few percent between sessions): - DEVICE_BUFFER, minimum / median of 5 runs: TAXI 5.49 -> 4.67 / 5.58 -> 4.79 ms (-15% / -14%), LOGS 3.17 -> 3.04 / 3.45 -> 3.05 ms (-4% / -12%), ANALYTICS 5.62 -> 4.92 / 5.67 -> 4.94 ms (-12% / -13%), QUOTE_0_PCT 6.63 -> 5.76 / 6.79 -> 5.82 ms (-13% / -14%), QUOTE_25_PCT 5.84 -> 5.58 / 5.99 -> 5.61 ms (-4% / -6%), QUOTE_100_PCT 9.31 -> 9.40 / 9.52 -> 9.53 ms (unchanged, see below). - Peak memory drops where no scratch memory is needed (no string column: ANALYTICS 849.5 -> 287.5 MiB, QUOTE_0_PCT 913.6 -> 265.1 MiB), and is unchanged otherwise (TAXI 903.7 MiB). - FILEPATH, HOST_BUFFER and PINNED_BUFFER (TAXI) and HOST_BUFFER (QUOTE_25_PCT): unchanged, within the noise of these runs (up to 5%). - Decoding data whose every string field has escaped quotes writes the unescaped strings to scratch memory rather than to cache lines just read: the decode kernel took 1645 us instead of 1486 us for 512 MiB (plus 0.7 ms saved on the copy). With the string columns of QUOTE_100_PCT, the slower decode takes about what the copy saves. Tests: device buffers at offsets 0 to 15 that end at the end of their allocation (BOM, comment and blank lines, escaped quotes at the start, middle and end of strings, quoted delimiters and terminators, CRLF, no final terminator), read with and without type inference, unescaping and quoting, with rows staged in shared memory and not staged (through LIBCUDF_CSV_ROW_STAGING), the same as from a host buffer and from a file, and left unchanged; empty, BOM-only and header-only device buffers and single fields at the end of the buffer; another quote character; device buffers of rows at every alignment; a user source with unaligned device data, parsed in place if it reports zero-copy reads (whether or not it prefers device reads) and copied otherwise, and copied with row selection. compute-sanitizer memcheck with exact allocations reports no error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Parsing a device buffer in place, the reader allocates scratch memory of the size of the input for the quoted strings it unescapes, whenever `doublequote` is set (the default) and a column is a string column, even if no field has an escaped quote pair. An escaped pair is two consecutive quote characters, and the single-pass row gathering already loads every character of the data. Its speculative pass now also records whether any quote character follows a quote character: in the word-loaded slices, from the quote bitmap it already computes (`quotes & (quotes << 1)`, and bit 0 if the character before the slice is a quote), in the others, character by character. The flag is copied to the host with the number of rows, in the synchronization that already sized the row offsets, and the reader only allocates the scratch memory if it is set. The flag ignores the parser state on purpose: it is a superset of the fields that decoding unescapes, whatever the options. A state-aware test could exclude empty quoted fields (`""`), but decoding also unescapes fields that the row state machine does not see as quoted (e.g. `, "a""b"` with detect_whitespace_around_quotes), so it could miss pairs that decoding writes. This helps reads of data with string columns and no two consecutive quote characters anywhere (no escaped quotes and no empty quoted fields). No CSV reader benchmark has such data: the TAXI, LOGS, QUOTE_25_PCT and QUOTE_100_PCT inputs have consecutive quotes, and QUOTE_0_PCT and ANALYTICS have no string column, so the previous commit already allocates no scratch memory for them. Measured on an RTX PRO 6000: - 256 MiB of rows `12345,678.5,s12345,t` read from a device buffer with inferred types: peak device memory 1439.5 -> 1183.5 MiB. With an empty quoted field in 10% of the rows, it is unchanged (1432.5 MiB). - Speculative row gathering pass over 512 MiB: 409 -> 412 us aligned, 873 -> 878 us unaligned; end-to-end times of the benchmarks are unchanged. Tests: the only escaped pair of the data at positions around the boundaries of 32-character slices, 16-byte words and 32KB tiles, in aligned and unaligned device buffers (an undetected pair makes the decode write to the missing scratch memory); a pair in a field that the row gathering does not consider quoted but decoding unescapes (detect_whitespace_around_quotes); the peak memory of a read with and without escaped pairs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
parse_numeric computes the place value of each fraction digit of a
floating-point field by dividing the previous one by 10: one double
division per digit. A double division is a long instruction sequence
(reciprocal approximation and refinement), and it is expensive on GPUs
with low FP64 throughput. The CSV reader and the JSON reader's type
casting (data_casting) both parse floats with this function.
The place value of a digit only depends on its position, so it is now
looked up in a table of the results of the repeated division. The table
is generated at compile time (constexpr) by the same division, and the
device reads a copy in constant memory. In the CSV reader, the threads
of a warp parse the same column of different rows and step through the
fraction digits together, so their lookups mostly read the same
element, which constant memory broadcasts (the JSON reader maps
threads to values differently; divergent indices only serialize the
constant-cache reads). The table holds the 323 nonzero results; the next
division underflows to zero (a static_assert checks that the last
element is at most 5 * denorm_min), and so does every later one, so
digits past the table get 0 as before. Base-16 parsing keeps the
division; no floating-point parse uses it.
Exactness: the device's double division is correctly rounded (round to
nearest even) and keeps subnormals, which is exactly how the constexpr
evaluation computes the table, so every place value, and every parsed
value, is unchanged. Checked by comparing the table with the device's
run-time repeated division (all 323 elements identical, the 324th and
325th divisions give 0), and by a differential run against the parent
build: 10M random strings (0-25 whole and fraction digits, 300-400
fraction digits, exponents around the double limits, subnormals,
printf-formatted random doubles and floats, malformed strings) read as
FLOAT64, FLOAT32 and INT64 by the CSV reader with three option sets and
as FLOAT64 and FLOAT32 by the JSON reader: identical bits and validity.
Measured on an RTX PRO 6000 (FP64 at 1/64 of the FP32 rate), decode
kernel time for about 256 MB of CSV read from a device buffer, median
of 80 reads:
- doubles near 1e+307, as the CSV writer writes the random values of
the ANALYTICS benchmark (2 INT64 + 6 FLOAT64 columns): 2044 -> 1070 us
- the same doubles among the TAXI benchmark's column types: 1789 ->
1401 us
- FLOAT32 values near 1000 ("872.6951904"): 2828 -> 963 us
- doubles near 1e8 without exponent ("87269516.69"): 1153 -> 677 us
- INT64 only: unchanged (779 -> 780 us)
These inputs are generated by a custom program, not the benchmarks. On
the CSV_READER_REALISTIC_NVBENCH data (256 MB, device buffer, minimum
GPU time), the ANALYTICS read goes from 4.5 to 3.2 ms end to end. All
measurements are on this sm_120 GPU with low FP64 throughput; the gain
is expected on every GPU (a double division is a long dependent
sequence everywhere), but was not measured on full-rate FP64 GPUs.
Test: FractionDigitPlaceValues reads fields with a single nonzero
fraction digit at positions 1-330 as FLOAT64 and FLOAT32 and compares
their bits with the repeated division computed on the host, and checks
that thousands separators and '+' in a fraction take no position.
JsonReaderTest.FloatingPointBits checks the bits of JSON FLOAT64 and
FLOAT32 reads of single fraction digits at positions 1, 2 and 322-324.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
parse_numeric accumulates the whole part of a number as
`value = value * 10 + digit` in the output type, so for FLOAT64 fields
it spends one double multiply-add per digit, which is slow on GPUs
with low FP64 throughput.
For doubles in base 10, the first 15 digits (numeric_limits<double>::
digits10) of the whole part are now accumulated in a uint64_t, and the
double accumulation continues from the converted value for any further
digits. The loop stops at the same characters as the double loop
(decimal point, 'e', 'E'), skips the same ones (thousands separator,
'+'), and decodes digits with the same validity check, leaving the
decimal point to the double loop.
Exactness: while the double accumulation has consumed at most 15
digits, every intermediate value is an integer below 10^15 < 2^53, and
`value * 10 + digit` of such integers is exact whether or not the
multiply-add is fused. So after the same digits the double loop would
hold exactly the integer, whose conversion to double is exact, and
continuing from it performs the same operations on the same values.
The bound is 15 and not 16 on purpose: the 16th step
can round, and taking it in the integer would only match if the
compiler fuses the multiply-add (it does by default, so 16 happens to
give the same results here, but that is not a guarantee). Leading
zeros count as digits, which only makes the integer part shorter. Other output types are unchanged: floats keep accumulating in
float (single precision is fast) and integer types already accumulate
in integers.
Checked by the differential run of the previous commit (10M random
strings, CSV with three option sets and JSON, FLOAT64/FLOAT32/INT64)
against the parent build: identical bits and validity.
Measured on an RTX PRO 6000, decode kernel time for about 256 MB of
CSV read from a device buffer, median of 80 reads: doubles near 1e8
written without exponent ("87269516.69", up to 9 whole digits):
677 -> 558 us. Doubles written with an exponent ("-4.743861625e+307")
have a single whole digit and are unchanged. The input is generated by
a custom program, not the benchmarks. This GPU (sm_120) has FP64 at
1/64 of the FP32 rate; on GPUs with full-rate FP64 (A100, H100, B200)
the 64-bit integer multiply-add chain replaces a single DFMA per
digit, and the effect there is unmeasured (possibly neutral).
Test: WholeDigitsOfDoubles pins 15, 16 and 17 whole digits, including
2^53 + 1 and 10 * (2^53 + 1), which round at the 16th digit (the
latter gives 10 * 2^53, not the nearest double to the decimal value,
so accumulating 17 digits in the integer would change it), leading
zeros, "-0", skipped thousands separators and '+', an invalid digit,
2^24 + 1 as FLOAT32, and upper-case exponents after 1, 2, 18 and 19
whole digits (compared with the same fields with a lower-case 'e').
FloatingPointSyntaxEdgeCases covers whole
parts that end at a decimal point or an exponent without digits ("1.",
".5", "1e", "1e+", "1e-") and exponents beyond the double range
("1e-400", "1e0400", "1e309", "0e400" -> NaN -> null).
JsonReaderTest.FloatingPointBits adds 16 whole digits, 2^53 + 1 and
upper-case exponents read by the JSON reader.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
to_timestamp, which the CSV reader and the JSON reader's type casting
use to parse timestamps, accepts many date and time layouts: it scans
for the end of the date, searches the date for '/' and then '-', and
searches the time of day for ':' and '.', after checking for an AM/PM
suffix. A common layout is ISO 8601 `YYYY-MM-DDTHH:MM:SS[.fraction]`,
which is how the CSV writer writes timestamps ("...SS.fffZ", or
"...SSZ" for seconds), and it has every separator at a fixed position.
Strings of 19 or more characters with digits and separators at those
positions ('T' or ' ' between date and time), that end after the
seconds, or with a 'Z' right after them, or continue with '.' and do
not end with 'M' or 'm', are now parsed directly from their fixed
positions.
Exactness: for such strings, the general parsing finds every
separator at the same fixed position, and then parses the same
characters into the same types and combines them the same way:
- the search for the end of the date sees no 'T' before position 10,
and counts the two '-' and the digit after the second one, so a ' '
at position 10 ends the date too;
- extract_date finds no '/', so it splits at the '-' at positions 4
and 7; the 4-digit first component makes the year come first
whatever `dayfirst` is;
- extract_time_of_day sees no AM/PM suffix (the last character is a
digit, 'Z', or not 'M'/'m'), finds ':' at 13 and 16, and '.' at 19 if
present; otherwise it parses the seconds from position 17 to the end,
where to_non_negative_integer ignores the 'Z'.
All other strings, including every near miss, take the general parsing
unchanged. The characters after the '.' are parsed as milliseconds, as
before (so the fast path keeps the general parsing's handling of
fractions and suffixes).
Checked by a differential run against the parent build: 10M strings
(the layout with random and out-of-range components, 'T' and ' ',
0-24 fraction digits including an empty fraction, suffixes such as Z,
+05:30, AM/PM, 'M'; mutated, truncated and extended near misses; and
general layouts), read by the CSV and JSON readers with dayfirst off
and on, as TIMESTAMP_DAYS/SECONDS/MILLISECONDS/MICROSECONDS/
NANOSECONDS: identical values.
Measured on an RTX PRO 6000, decode kernel time for about 256 MB of
CSV with the TAXI benchmark's column types (two TIMESTAMP_MILLISECONDS
columns written by the CSV writer) read from a device buffer, median
of 80 reads: 1408 -> 1252 us.
Tests: FixedLayoutIsoTimestamps (CSV and JSON) reads each string next
to the same string with '/' date separators, which takes the general
parsing and parses '/'-free strings the same way, and compares them
for all timestamp types (JSON: seconds and nanoseconds) and both
dayfirst settings. The strings (new io_test_utils.hpp helpers) are
hand-picked strings of the layout (both separators, 'Z', 0-12 fraction
digits, "SS.", suffixes, AM/PM, out-of-range components) and about
2,000 generated near misses: three strings of the layout truncated to
every length, and with a character deleted, or one of "0 -.:MPTZmtxz"
substituted or inserted, at every position, or with a suffix such as
"Z5", "Z+05:30" or " PM" appended. The CSV test also pins the current
behavior that a fraction's digits count milliseconds whatever their
number (".5" is 5 ms, which the fast path keeps).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A serialized trie lookup compares the key with the nodes one character at a time and stops at the first character without a matching child. The CSV reader looks up every field in the NA trie, and fields decoded or classified as numbers or booleans also in the true and false tries; type detection looks up every field in all three. Most fields are longer than every key (the longest default NA value is the 8 characters "#N/A N/A"), but many share a prefix with one: numbers that start with "1" or "-" walk into "1.#IND", "-1.#QNAN" or "-nan" before they fail to match. The root node's children offset is never used: its children are the nodes that follow it, where lookups start. Store the length of the longest key there when the trie is built, and reject longer keys before the walk. A key longer than every key cannot be equal to one, so every result is unchanged. If the length does not fit in the 16-bit offset, the root keeps its offset of -1 and lookups walk the trie as before. The JSON reader and JSON type inference use the same tries and lookup. This also removes an out-of-bounds read: the trie of the empty key alone is its root node, and a lookup of any other key read the node after it. The CSV and JSON readers never build such a trie (they add default keys), but other users of `create_serialized_trie` can. New tests: a CSV read of keys that are prefixes of each other, the empty key, a duplicate key and non-ASCII keys, against fields that are equal to them, longer, or prefixes of them, with explicit and inferred types, and with the rows staged in shared memory and not (LIBCUDF_CSV_ROW_STAGING); and a new TRIE_TEST of lookups on the device, including the trie of the empty key alone. The effect depends on how many fields share a prefix with a key, and the measurements are noisy. Kernel durations of the 256 MB benchmarks, as ranges over three sets of interleaved nsys runs on an RTX PRO 6000 shared with other jobs (two at this commit, one at the end of the series, whose later commits do not change these kernels' work): - decode: TAXI -4% to -5%, LOGS -5% to -7%, ANALYTICS -8% to +1%; with inferred types and 64 columns, ALL_INTEGRAL -6% to -9%, ALL_STRING -2% to -4%, MIXED -6% to +4%; - type detection: ALL_STRING -18% to -21%, MIXED -12% to -20%, ALL_INTEGRAL -2% to -11%. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`create_serialized_trie` builds a tree of heap-allocated nodes, each with 256 child pointers, before serializing it breadth-first. For the CSV reader's default NA values this takes about 15 us on the host, and every read builds three tries (NA, true and false values). The builder also keeps node indexes, and the serialized trie its children offsets, in 16-bit integers. In a trie of more than 32767 nodes, a node can be more than 32767 nodes before its first child, and the children of such a node were silently lost: the keys below it never matched. For example, of the 40000 NA values "k00000" to "k39999", "k39999" did not match, in both the CSV and the JSON reader. Serialize the trie directly from the distinct keys in sorted order. The keys below a node are then consecutive, with the key that ends at the node first, and the others grouped by their next character in ascending order: each group is a child. A breadth-first walk over (node, key range) pairs emits the same nodes, in the same order and with the same offsets, as the tree walk did. `std::string_view` compares characters as unsigned char, which is the order of the children that `serialized_trie_contains` relies on for non-ASCII keys. Store the children offsets in 32-bit integers, so that tries of any practical size are correct; a trie too large even for that throws std::overflow_error instead of losing keys. The length of the longest key now always fits in the root, so lookups no longer handle a root without it. Nodes grow from 4 to 8 bytes: in the same kernel measurements as the previous commit, the decode and type detection kernels show no consistent change. The output was compared with the previous builder on 30000 random sets of up to 24 keys (with duplicates, the empty key, and every byte value but the terminating '\n'): the nodes are identical in every case. Building the default NA trie takes 0.6 us instead of 15.2 us, and the default true or false trie 0.13 us instead of 2.9 us. The new CSV, JSON and TRIE_TEST tests of a trie of 40000 keys, and the TRIE_TEST test of a key of 40000 characters, fail without this change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`make_parse_options` builds the tries of the true, false and NA values with a `create_serialized_trie` each, and each one uploads its nodes with a synchronous copy: three stream synchronizations at the start of every read_csv. Add `create_serialized_tries`, which serializes several sets of keys, uploads all their tries, and then synchronizes the stream once. The synchronization is still needed: the copies may read the host nodes only when the stream reaches them, so the nodes must outlive the copies. The CSV reader builds its three tries with it. A set without keys (the NA values with na_filter off) gives an empty trie, which matches nothing, like the empty trie the reader created before. A read makes two fewer stream synchronizations (nsys, 1 KiB file of 8 columns with inferred types: 30 instead of 32). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`decode_data` allocates each output buffer with `column_buffer::create`, which allocates an all-null mask with a memset of its own, and zeroes the (pointer, length) pairs of each string column with another memset. The masks of string columns are never used: string columns get their null masks from the decoded pairs, when they are built. Allocate the masks of the other columns uninitialized, and zero them all with `cudf::detail::batched_memset`, as the Parquet reader does; give the buffers of string columns no mask. Each mask is zeroed over its whole allocation, as `create_null_mask` does, so the masks are the same, including the padding past the last row. Instead of one memset per column, a read makes one upload of the mask spans and the three kernel launches of `cub::DeviceCopy::Batched`, a fixed cost that pays off with more than a handful of columns (nsys, per read of a 1 KiB file: 8 columns, 2 of them strings, 8 memsets instead of 16, plus 1 copy and 3 launches; 64 columns, 16 of them strings, 35 memsets instead of 99). The new test reads 100 columns of every decoded type, strings among them, and checks every null mask up to the end of its allocation, with the rows staged in shared memory and not. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Type detection uploads the column flags and zeroes its counters with a memset, then copies the counters back into a new pinned host vector. Decoding uploads the column flags, the column types, and the column data and mask pointers with four copies, from two new pinned host vectors. Allocating pinned host memory synchronizes the stream, so the counters' allocation waits for the detection kernel, and the read then waits again for their copy; `infer_column_types` also synchronizes once more after detection, for nothing. Add `hostdevice_arrays`, which places arrays of different element types in one `hostdevice_vector`, each at an offset aligned for its type, and returns them as `hostdevice_span`s. Like `hostdevice_vector`, it copies all arrays between the host and the device at once, and skips the copies when the device accesses the host memory directly (the integrated memory optimization). Detection fills its flags and zeroed counters on the host, uploads them with one copy, and copies them back to the same host memory, which is allocated before the kernel is launched. Decoding allocates its four arrays first, while the stream is idle, fills them as it allocates the output buffers, and uploads them with one copy. The column flags and types that `read_csv` keeps on the host are no longer uploaded directly, so they no longer need pinned memory, whose allocations synchronized the stream too. The new SPAN_TEST tests check the layout and the copies, with and without the integrated memory optimization. A read makes four fewer stream synchronizations, three fewer copies and one fewer memset (nsys, 1 KiB file of 8 columns with inferred types: 26 synchronizations instead of 30, 23 copies instead of 26). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
convert_csv_to_cudf read column_flags[col] and dtypes[actual_col] from global memory at every use, up to eight times per field. The kernel stores the decoded values through untyped column pointers, which the compiler must assume can alias the flags and types, so it cannot keep them in registers across those stores and reloads them after each one. Load the column's flags once per field, and its type once per enabled field (only enabled columns have a type: `actual_col` can run past the end of `dtypes` for disabled trailing columns). The values are the same as before, so the output is unchanged. This is a code clarity and code generation change with no measurable end-to-end effect: register and stack usage of both kernel instances is unchanged, and decode kernel time moves by -0.5% to -0.9% (TAXI, QUOTE_100, LOGS; nsys, 256 MB inputs, median of 20 reads, two interleaved runs), which is within noise. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The CSV reader built each string column separately with make_strings_column: a scan for the offsets, a valid_if kernel whose null count synchronizes the stream, and cub::DeviceMemcpy::Batched for the characters. A read with 64 string columns (QUOTE_100) ran some 450 small kernels and 64 synchronizations, and the batched memcpy alone took 1.8 ms of its 4.6 ms of GPU time. Build all the string columns of a read together, in a new make_strings_columns (io/csv/string_columns.cu), called by decode_data, which now returns the finished columns: - compute_validity_and_sizes_kernel reads the (pointer, size) pairs of a block of 256 rows of one column per thread block. Each warp writes the mask word of its 32 rows, and the block adds its valid rows and characters to the column's counts and records its characters. - A scan by column of the blocks' characters gives each block's offset. - One copy of the counts to the host, the only synchronization of the string columns, sizes the offsets (64-bit from LIBCUDF_LARGE_STRINGS_THRESHOLD on, through create_offsets_child_column, which also raises std::overflow_error when large strings are disabled, as before) and the characters. The fixed-width columns' null counts still take their own synchronization (batch_null_count), so a decode has two. - write_strings_kernel scans the sizes within each block, writes the offsets, and copies the characters. The strings of a warp's 32 rows are contiguous in the output, and the warp writes them in windows of one aligned 4-byte word per lane, which it assembles from the bytes of the strings the word overlaps (found by a binary search over the rows' offsets in shared memory). Words that overlap other warps' characters are stored byte by byte, so warps never write each other's bytes. The blocks of the same rows of all columns are adjacent in the grid, so their strings, which are close in the source, share cached reads. Each block handles a single block of rows (the grid is 2D only for more blocks than its x dimension allows): a grid-stride loop costs the kernel six registers, and the copy needs the occupancy. The number of blocks of rows comes from the host, so it cannot overflow. The warp copy is fast for the short strings typical of CSV, but a warp copies about one window per memory latency, so long strings would keep single warps busy, and few rows of long strings leave too few warps to hide the latency (1 MiB strings: 220 ms instead of 0.35 ms). Strings of more than 1 KiB are therefore listed by the warps and copied afterwards with one batched memcpy; they split a warp's rows into runs whose strings are copied as above. With strings of a single size, the warp copy is faster up to about 1 KiB and the batched memcpy from about 2 KiB. This cutoff (and the 256-row blocks) were measured on one GPU (RTX PRO 6000 Blackwell, sm_120). The worst case is inputs made only of strings of exactly 1 KiB, the longest the warps copy: between +1% and +14% of GPU time per 256 MB read in separate runs (+11 to +190 us; the high end was measured with the grid-stride loop described above). Inputs of 2-4 KiB strings take up to 80 us (5%) more, for the two kernels that the batched memcpy now follows. Fields longer than a string can be (2^31 - 1 characters) were truncated to 32 bits before: fields of 2^31 to 2^32 - 1 characters got negative sizes, and the read failed with an allocation of about 16 EiB, while longer fields were silently read as wrong strings or nulls. The decode kernel now writes a distinct size for them, the validity kernel counts them (without adding them to the character counts), and make_strings_columns raises std::overflow_error on the host before any character is copied. Its pairs are now written as cuda::std::pair<char const*, size_type>, the type the string buffers hold, rather than as std::pair<char const*, size_t>. The characters are always copied, so the columns never alias the input, which can be the caller's device buffer. The builder only depends on the (pointer, size) pairs; it lives with the CSV reader because it relies on all columns having the same number of rows, and because make_strings_column_batch, which it could replace, has other users and columns of different lengths. Moving the warp copy to cudf::strings::detail (make_chars_buffer, make_strings_column_batch) is a possible follow-up. Tests (each under LIBCUDF_CSV_ROW_STAGING=ALWAYS and NEVER unless noted): - StringColumnsAcrossWarpsAndBlocks: 1 to 1000 rows around the warp and block sizes; all-null, empty, edge-null and escaped-quote columns. - ManyStringColumnsWithMissingFields: 70 columns, rows missing up to 69 trailing fields, selected columns. - MoreStringColumnsThanGridDimension: 70,000 columns (one policy; the decoding is not what it tests). - LongStringsAmongShortStrings: 1000 B to 1 MiB strings at warp and block edges. - StringColumnsWithLargeOffsets: 64-bit offsets from a low LIBCUDF_LARGE_STRINGS_THRESHOLD, std::overflow_error without large strings. - StringsOfDeviceBuffersAreCopied: the columns do not change when the source device buffer is overwritten after the read. - InferredStringColumnsOfRaggedRows, WhitespaceDelimitedStringColumnsOf- ShortRows, SelectedStringColumnsOfShortRows, LeadingStringColumnsOf- ShortRows: fields missing from short and blank rows with inferred types, delim_whitespace (including rows of whitespace only, the last one without a terminator) and use_cols (including 68 trailing columns that are not selected). The whitespace-delimited test pins the current strings, which keep all but one of the whitespace characters that follow a field, as before this change. - LongStringsAtEndOfUnalignedDeviceBuffers: short, long and escaped strings of device buffers at every alignment, ending at the end of their allocations. - StringColumnsOfByteRangesAndRowSelections, HeaderOnlyStringColumns. - LARGE_STRINGS_TEST CsvLargeReaderTest.StringColumnOverTwoGigabytes (a column of more than 2^31 characters, compared byte for byte) and DISABLED_FieldLongerThanStringSizeLimit (std::overflow_error; disabled because the one thread of its 2 GiB row takes about a minute; before this change the read failed with a 16 EiB allocation instead). The tests of fields missing from short rows and of long strings fill every device allocation with a byte pattern first, so that memory the reader reads without writing it changes their results deterministically. The csv_test.cpp environment variable guard of the row staging policy is generalized to set the large strings variables too. Measured before the rebase onto the host latency changes, on the float and timestamp parsing changes: GPU time per read of the 256 MB benchmark inputs (nsys, all kernels and memsets, median of 20 reads): QUOTE_100 4601 -> 2696 us, QUOTE_25 4058 -> 3623 us, LOGS 2206 -> 1955 us, TAXI 3220 -> 3190 us, ALL_STRING with type inference 6688 -> 4781 us. Reader benchmarks (nvbench, best of 3 interleaved runs): QUOTE_100 8.37 -> 3.34 ms (-60%), ALL_STRING 8.37 -> 3.22 ms (-61%) and 10.48 -> 5.27 ms with inference (-50%), QUOTE_25 5.18 -> 3.90 ms (-25%), LOGS 2.51 -> 2.16 ms (-14%); TAXI and ANALYTICS within noise. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The decode kernel wrote a 16-byte (pointer, size) pair per row of each string column, into arrays that had to be zeroed beforehand: fields missing from the end of short rows were not written, and relied on the zeroed pair reading as null. The zeroing wrote as much memory as the kernel (140 us per QUOTE_100 read), and the string column kernels read all 16 bytes of every pair, although they only need the sizes to compute validity and offsets. Decode the strings of a string column to `decoded_strings`: the pointers of all rows, followed by their 4-byte sizes, with a size of -1 for a null row (whose pointer is not written) and -2 for a field too long for a string. The validity and sizes kernel now reads 4 bytes per row, and the write kernel reads pointers only for non-empty strings. The memory is not initialized: the decode kernel now also sets the strings of the fields missing from the end of a short row to null, after the row's last field, so every row of every string column is written. The tests of the previous commit on short, blank and whitespace-delimited rows and ShortRowsMissingFieldsAreNull cover this, and compute-sanitizer initcheck reports no read of the decoded strings: over the reader tests, the only reports in the string kernels are 1709 one-byte reads of source characters in write_strings_kernel, all in tests that read files, the same number for each test as the parent's reports in cub's batched memcpy. Where the sizes start depends on the number of rows, so the host passes the number of rows the memory is sized for to decode_row_column_data, which checks it against the row offsets, instead of both sides deriving it. The decoded strings of all string columns share one scratch allocation, and the string columns no longer get column buffers. Measured before the rebase onto the host latency changes, on the float and timestamp parsing changes: GPU time per read of the 256 MB benchmark inputs (nsys, all kernels and memsets, median of 20 reads): QUOTE_100 2696 -> 2415 us (-10%), LOGS 1955 -> 1786 us (-9%), QUOTE_25 3623 -> 3417 us (-6%), ALL_STRING with type inference 4781 -> 4475 us (-6%), TAXI 3190 -> 3141 us (-2%). Reader benchmarks (nvbench, best of 3 interleaved runs): QUOTE_100 3.34 -> 2.72 ms (-19%), ALL_STRING 3.22 -> 2.77 ms (-14%) and 5.27 -> 4.65 ms with inference (-12%), QUOTE_25 3.90 -> 3.63 ms (-7%), LOGS 2.16 -> 2.03 ms (-6%); ANALYTICS (no strings) unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Methodology (clean re-measurement). All ten builds of the stack (base and PRs 1–9) were measured in one session on the same GPU (host GPU 1), in two interleaved rounds (base, PR 1, …, PR 9, then again), so drift affects every build equally. Each benchmark run started only when no other process was using the GPU and no build/test/sanitizer/fuzzer was running on the host in our environment, and would have been re-run if anything appeared during it; all 160 runs were clean on the first attempt. Each number below is the best of the two rounds per side. Per-PR contributions are in the benchmark comments on #16–#24. Note PR 6 (#21) trades ~4% on pageable-host and file reads for its device-buffer gain. CSV reader benchmarks: combined stack (#16–#24) vs coral/csv-baseThis PR Speedup vs base (cumulative), by input source
Sources: the Full results: every CSV reader benchmark config
|
| Benchmark | Config | Base | This PR | vs base |
|---|---|---|---|---|
| csv_read_column_selection | column_selection=ALL, num_chunks=1, 10BC8 row_selection=ALL | 2.63 GiB/s | 3.50 GiB/s | 1.33× |
| csv_read_column_selection | column_selection=ALTERNATE, num_chunks=1, row_selection=ALL | 1.45 GiB/s | 1.77 GiB/s | 1.22× |
| csv_read_column_selection | column_selection=FIRST_HALF, num_chunks=1, row_selection=ALL | 1.49 GiB/s | 1.84 GiB/s | 1.24× |
| csv_read_column_selection | column_selection=SECOND_HALF, num_chunks=1, row_selection=ALL | 1.49 GiB/s | 1.84 GiB/s | 1.23× |
| csv_read_data_type | data_type=DECIMAL, io=DEVICE_BUFFER | 15.33 GiB/s | 44.78 GiB/s | 2.92× |
| csv_read_data_type | data_type=DURATION, io=DEVICE_BUFFER | 12.78 GiB/s | 40.74 GiB/s | 3.19× |
| csv_read_data_type | data_type=FLOAT, io=DEVICE_BUFFER | 18.66 GiB/s | 49.68 GiB/s | 2.66× |
| csv_read_data_type | data_type=INTEGRAL, io=DEVICE_BUFFER | 17.43 GiB/s | 47.83 GiB/s | 2.74× |
| csv_read_data_type | data_type=STRING, io=DEVICE_BUFFER | 4.68 GiB/s | 82.10 GiB/s | 17.53× |
| csv_read_data_type | data_type=TIMESTAMP, io=DEVICE_BUFFER | 12.37 GiB/s | 48.06 GiB/s | 3.88× |
| csv_read_io | io=FILEPATH | 4.55 GiB/s | 6.91 GiB/s | 1.52× |
| csv_read_io | io=HOST_BUFFER | 3.38 GiB/s | 3.74 GiB/s | 1.10× |
| csv_read_row_selection | column_selection=ALL, num_chunks=1, row_selection=BYTE_RANGE | 2.56 GiB/s | 3.23 GiB/s | 1.26× |
| csv_read_row_selection | column_selection=ALL, num_chunks=1, row_selection=NROWS | 2.54 GiB/s | 3.22 GiB/s | 1.27× |
| csv_read_row_selection | column_selection=ALL, num_chunks=1, row_selection=SKIPFOOTER | 2.67 GiB/s | 3.48 GiB/s | 1.30× |
| csv_read_row_selection | column_selection=ALL, num_chunks=8, row_selection=BYTE_RANGE | 1.93 GiB/s | 3.16 GiB/s | 1.64× |
| csv_read_row_selection | column_selection=ALL, num_chunks=8, row_selection=NROWS | 0.71 GiB/s | 0.75 GiB/s | 1.06× |
| csv_read_row_selection | column_selection=ALL, num_chunks=8, row_selection=SKIPFOOTER | 0.45 GiB/s | 0.45 GiB/s | 1.01× |
CSV_READER_QUOTING_IO_NVBENCH (12 configs)
| Benchmark | Config | Base | This PR | vs base |
|---|---|---|---|---|
| csv_read_quoting_io | data_size_mb=256, io=DEVICE_BUFFER, quote_pct=QUOTE_0_PCT | 17.11 GiB/s | 48.14 GiB/s | 2.81× |
| csv_read_quoting_io | data_size_mb=256, io=DEVICE_BUFFER, quote_pct=QUOTE_100_PCT | 4.87 GiB/s | 83.50 GiB/s | 17.15× |
| csv_read_quoting_io | data_size_mb=256, io=DEVICE_BUFFER, quote_pct=QUOTE_25_PCT | 12.75 GiB/s | 65.28 GiB/s | 5.12× |
| csv_read_quoting_io | data_size_mb=256, io=FILEPATH, quote_pct=QUOTE_0_PCT | 5.25 GiB/s | 7.86 GiB/s | 1.50× |
| csv_read_quoting_io | data_size_mb=256, io=FILEPATH, quote_pct=QUOTE_100_PCT | 4.69 GiB/s | 20.53 GiB/s | 4.38× |
| csv_read_quoting_io | data_size_mb=256, io=FILEPATH, quote_pct=QUOTE_25_PCT | 6.47 GiB/s | 13.20 GiB/s | 2.04× |
| csv_read_quoting_io | data_size_mb=256, io=HOST_BUFFER, quote_pct=QUOTE_0_PCT | 3.80 GiB/s | 4.52 GiB/s | 1.19× |
| csv_read_quoting_io | data_size_mb=256, io=HOST_BUFFER, quote_pct=QUOTE_100_PCT | 4.18 GiB/s | 11.64 GiB/s | 2.78× |
| csv_read_quoting_io | data_size_mb=256, io=HOST_BUFFER, quote_pct=QUOTE_25_PCT | 4.99 GiB/s | 7.68 GiB/s | 1.54× |
| csv_read_quoting_io | data_size_mb=256, io=PINNED_BUFFER, quote_pct=QUOTE_0_PCT | 9.57 GiB/s | 14.21 GiB/s | 1.49× |
| csv_read_quoting_io | data_size_mb=256, io=PINNED_BUFFER, quote_pct=QUOTE_100_PCT | 5.40 GiB/s | 36.61 GiB/s | 6.78× |
| csv_read_quoting_io | data_size_mb=256, io=PINNED_BUFFER, quote_pct=QUOTE_25_PCT | 9.77 GiB/s | 23.38 GiB/s | 2.39× |
CSV_READER_QUOTING_NVBENCH (6 configs)
| Benchmark | Config | Base | This PR | vs base |
|---|---|---|---|---|
| csv_read_quoting | data_size_mb=256, quote_pct=QUOTE_0_PCT | 17.51 GiB/s | 48.46 GiB/s | 2.77× |
| csv_read_quoting | data_size_mb=256, quote_pct=QUOTE_100_PCT | 4.80 GiB/s | 81.74 GiB/s | 17.04× |
| csv_read_quoting | data_size_mb=256, quote_pct=QUOTE_25_PCT | 13.88 GiB/s | 65.53 GiB/s | 4.72× |
| csv_read_quoting | data_size_mb=64, quote_pct=QUOTE_0_PCT | 14.10 GiB/s | 34.19 GiB/s | 2.42× |
| csv_read_quoting | data_size_mb=64, quote_pct=QUOTE_100_PCT | 1.37 GiB/s | 46.36 GiB/s | 33.91× |
| csv_read_quoting | data_size_mb=64, quote_pct=QUOTE_25_PCT | 4.57 GiB/s | 45.54 GiB/s | 9.97× |
CSV_READER_REALISTIC_IO_NVBENCH (12 configs)
| Benchmark | Config | Base | This PR | vs base |
|---|---|---|---|---|
| csv_read_realistic_io | data_size_mb=256, io=DEVICE_BUFFER, profile=ANALYTICS | 25.28 GiB/s | 74.41 GiB/s | 2.94× |
| csv_read_realistic_io | data_size_mb=256, io=DEVICE_BUFFER, profile=LOGS | 27.19 GiB/s | 109.22 GiB/s | 4.02× |
| csv_read_realistic_io | data_size_mb=256, io=DEVICE_BUFFER, profile=TAXI | 18.91 GiB/s | 73.05 GiB/s | 3.86× |
| csv_read_realistic_io | data_size_mb=256, io=FILEPATH, profile=ANALYTICS | 6.63 GiB/s | 9.19 GiB/s | 1.38× |
| csv_read_realistic_io | data_size_mb=256, io=FILEPATH, profile=LOGS | 9.85 GiB/s | 15.58 GiB/s | 1.58× |
| csv_read_realistic_io | data_size_mb=256, io=FILEPATH, profile=TAXI | 6.37 GiB/s | 10.20 GiB/s | 1.60× |
| csv_read_realistic_io | data_size_mb=256, io=HOST_BUFFER, profile=ANALYTICS | 4.83 GiB/s | 5.11 GiB/s | 1.06× |
| csv_read_realistic_io | data_size_mb=256, io=HOST_BUFFER, profile=LOGS | 7.16 GiB/s | 8.62 GiB/s | 1.20× |
| csv_read_realistic_io | data_size_mb=256, io=HOST_BUFFER, profile=TAXI | 4.77 GiB/s | 5.60 GiB/s | 1.17× |
| csv_read_realistic_io | data_size_mb=256, io=PINNED_BUFFER, profile=ANALYTICS | 12.58 GiB/s | 17.97 GiB/s | 1.43× |
| csv_read_realistic_io | data_size_mb=256, io=PINNED_BUFFER, profile=LOGS | 17.05 GiB/s | 29.87 GiB/s | 1.75× |
| csv_read_realistic_io | data_size_mb=256, io=PINNED_BUFFER, profile=TAXI | 11.20 GiB/s | 18.58 GiB/s | 1.66× |
CSV_READER_REALISTIC_NVBENCH (9 configs)
| Benchmark | Config | Base | This PR | vs base |
|---|---|---|---|---|
| csv_read_realistic | data_size_mb=1024, profile=ANALYTICS | 25.11 GiB/s | 89.74 GiB/s | 3.57× |
| csv_read_realistic | data_size_mb=1024, profile=LOGS | 31.88 GiB/s | 126.78 GiB/s | 3.98× |
| csv_read_realistic | data_size_mb=1024, profile=TAXI | 23.43 GiB/s | 88.40 GiB/s | 3.77× |
| csv_read_realistic | data_size_mb=256, profile=ANALYTICS | 25.24 GiB/s | 78.19 GiB/s | 3.10× |
| csv_read_realistic | data_size_mb=256, profile=LOGS | 27.37 GiB/s | 112.29 GiB/s | 4.10× |
| csv_read_realistic | data_size_mb=256, profile=TAXI | 19.01 GiB/s | 72.76 GiB/s | 3.83× |
| csv_read_realistic | data_size_mb=512, profile=ANALYTICS | 25.64 GiB/s | 85.21 GiB/s | 3.32× |
| csv_read_realistic | data_size_mb=512, profile=LOGS | 31.05 GiB/s | 129.74 GiB/s | 4.18× |
| csv_read_realistic | data_size_mb=512, profile=TAXI | 22.88 GiB/s | 82.61 GiB/s | 3.61× |
CSV_READER_SCALE_NVBENCH (5 configs)
| Benchmark | Config | Base | This PR | vs base |
|---|---|---|---|---|
| csv_read_scale | data_size_mb=1024 | 3.54 GiB/s | 3.91 GiB/s | 1.10× |
| csv_read_scale | data_size_mb=2048 | 3.52 GiB/s | 3.91 GiB/s | 1.11× |
| csv_read_scale | data_size_mb=256 | 3.59 GiB/s | 3.89 GiB/s | 1.08× |
| csv_read_scale | data_size_mb=4096 | 3.38 GiB/s | 3.97 GiB/s | 1.17× |
| csv_read_scale | data_size_mb=512 | 3.60 GiB/s | 3.92 GiB/s | 1.09× |
CSV_READER_TYPE_INFERENCE_IO_NVBENCH (64 configs)
| Benchmark | Config | Base | This PR | vs base |
|---|---|---|---|---|
| csv_read_with_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=64, type_profile=ALL_FLOAT | 10.11 GiB/s | 25.28 GiB/s | 2.50× |
| csv_read_with_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=64, type_profile=ALL_INTEGRAL | 9.02 GiB/s | 25.90 GiB/s | 2.87× |
| csv_read_with_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=64, type_profile=ALL_STRING | 4.40 GiB/s | 54.43 GiB/s | 12.36× |
| csv_read_with_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=64, type_profile=MIXED | 6.59 GiB/s | 25.42 GiB/s | 3.86× |
| csv_read_with_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=8, type_profile=ALL_FLOAT | 12.31 GiB/s | 40.34 GiB/s | 3.28× |
| csv_read_with_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=8, type_profile=ALL_INTEGRAL | 21.14 GiB/s | 58.63 GiB/s | 2.77× |
| csv_read_with_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=8, type_profile=ALL_STRING | 11.91 GiB/s | 71.39 GiB/s | 5.99× |
| csv_read_with_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=8, type_profile=MIXED | 21.15 GiB/s | 58.88 GiB/s | 2.78× |
| csv_read_with_inference_io | data_size_mb=256, io=FILEPATH, num_cols=64, type_profile=ALL_FLOAT | 4.03 GiB/s | 6.22 GiB/s | 1.54× |
| csv_read_with_inference_io | data_size_mb=256, io=FILEPATH, num_cols=64, type_profile=ALL_INTEGRAL | 4.12 GiB/s | 6.72 GiB/s | 1.63× |
| csv_read_with_inference_io | data_size_mb=256, io=FILEPATH, num_cols=64, type_profile=ALL_STRING | 3.94 GiB/s | 18.36 GiB/s | 4.66× |
| csv_read_with_inference_io | data_size_mb=256, io=FILEPATH, num_cols=64, type_profile=MIXED | 3.16 GiB/s | 6.49 GiB/s | 2.05× |
| csv_read_with_inference_io | data_size_mb=256, io=FILEPATH, num_cols=8, type_profile=ALL_FLOAT | 4.37 GiB/s | 7.04 GiB/s | 1.61× |
| csv_read_with_inference_io | data_size_mb=256, io=FILEPATH, num_cols=8, type_profile=ALL_INTEGRAL | 5.98 GiB/s | 7.97 GiB/s | 1.33× |
| csv_read_with_inference_io | data_size_mb=256, io=FILEPATH, num_cols=8, type_profile=ALL_STRING | 7.49 GiB/s | 18.70 GiB/s | 2.50× |
| csv_read_with_inference_io | data_size_mb=256, io=FILEPATH, num_cols=8, type_profile=MIXED | 5.79 GiB/s | 8.63 GiB/s | 1.49× |
| csv_read_with_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=64, type_profile=ALL_FLOAT | 3.07 GiB/s | 3.67 GiB/s | 1.20× |
| csv_read_with_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=64, type_profile=ALL_INTEGRAL | 3.21 GiB/s | 3.79 GiB/s | 1.18× |
| csv_read_with_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=64, type_profile=ALL_STRING | 3.50 GiB/s | 11.06 GiB/s | 3.16× |
| csv_read_with_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=64, type_profile=MIXED | 2.60 GiB/s | 3.78 GiB/s | 1.45× |
| csv_read_with_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=8, type_profile=ALL_FLOAT | 3.30 GiB/s | 3.75 GiB/s | 1.14× |
| csv_read_with_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=8, type_profile=ALL_INTEGRAL | 4.04 GiB/s | 4.53 GiB/s | 1.12× |
| csv_read_with_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=8, type_profile=ALL_STRING | 6.36 GiB/s | 11.43 GiB/s | 1.80× |
| csv_read_with_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=8, type_profile=MIXED | 4.06 GiB/s | 4.67 GiB/s | 1.15× |
| csv_read_with_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=64, type_profile=ALL_FLOAT | 6.80 GiB/s | 10.86 GiB/s | 1.60× |
| csv_read_with_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=64, type_profile=ALL_INTEGRAL | 6.43 GiB/s | 11.43 GiB/s | 1.78× |
| csv_read_with_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=64, type_profile=ALL_STRING | 4.36 GiB/s | 29.75 GiB/s | 6.82× |
| csv_read_with_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=64, type_profile=MIXED | 4.90 GiB/s | 10.74 GiB/s | 2.19× |
| csv_read_with_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=8, type_profile=ALL_FLOAT | 7.73 GiB/s | 12.97 GiB/s | 1.68× |
| csv_read_with_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=8, type_profile=ALL_INTEGRAL | 10.97 GiB/s | 15.69 GiB/s | 1.43× |
| csv_read_with_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=8, type_profile=ALL_STRING | 10.30 GiB/s | 33.90 GiB/s | 3.29× |
| csv_read_with_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=8, type_profile=MIXED | 10.97 GiB/s | 15.75 GiB/s | 1.44× |
| csv_read_without_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=64, type_profile=ALL_FLOAT | 18.66 GiB/s | 48.62 GiB/s | 2.60× |
| csv_read_without_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=64, type_profile=ALL_INTEGRAL | 17.43 GiB/s | 49.31 GiB/s | 2.83× |
| csv_read_without_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=64, type_profile=ALL_STRING | 4.91 GiB/s | 81.90 GiB/s | 16.67× |
| csv_read_without_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=64, type_profile=MIXED | 14.50 GiB/s | 50.83 GiB/s | 3.50× |
| csv_read_without_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=8, type_profile=ALL_FLOAT | 21.45 GiB/s | 57.74 GiB/s | 2.69× |
| csv_read_without_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=8, type_profile=ALL_INTEGRAL | 28.99 GiB/s | 92.27 GiB/s | 3.18× |
| csv_read_without_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=8, type_profile=ALL_STRING | 20.79 GiB/s | 101.96 GiB/s | 4.91× |
| csv_read_without_inference_io | data_size_mb=256, io=DEVICE_BUFFER, num_cols=8, type_profile=MIXED | 29.16 GiB/s | 98.25 GiB/s | 3.37× |
| csv_read_without_inference_io | data_size_mb=256, io=FILEPATH, num_cols=64, type_profile=ALL_FLOAT | 5.12 GiB/s | 7.41 GiB/s | 1.45× |
| csv_read_without_inference_io | data_size_mb=256, io=FILEPATH, num_cols=64, type_profile=ALL_INTEGRAL | 5.02 GiB/s | 8.11 GiB/s | 1.61× |
| csv_read_without_inference_io | data_size_mb=256, io=FILEPATH, num_cols=64, type_profile=ALL_STRING | 4.78 GiB/s | 20.46 GiB/s | 4.28× |
| csv_read_without_inference_io | data_size_mb=256, io=FILEPATH, num_cols=64, type_profile=MIXED | 4.60 GiB/s | 7.23 GiB/s | 1.57× |
| csv_read_without_inference_io | data_size_mb=256, io=FILEPATH, num_cols=8, type_profile=ALL_FLOAT | 5.29 GiB/s | 7.69 GiB/s | 1.45× |
| csv_read_without_inference_io | data_size_mb=256, io=FILEPATH, num_cols=8, type_profile=ALL_INTEGRAL | 6.01 GiB/s | 9.00 GiB/s | 1.50× |
| csv_read_without_inference_io | data_size_mb=256, io=FILEPATH, num_cols=8, type_profile=ALL_STRING | 11.19 GiB/s | 21.69 GiB/s | 1.94× |
| csv_read_without_inference_io | data_size_mb=256, io=FILEPATH, num_cols=8, type_profile=MIXED | 6.39 GiB/s | 8.79 GiB/s | 1.38× |
| csv_read_without_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=64, type_profile=ALL_FLOAT | 3.75 GiB/s | 4.11 GiB/s | 1.09× |
| csv_read_without_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=64, type_profile=ALL_INTEGRAL | 3.79 GiB/s | 4.44 GiB/s | 1.17× |
| csv_read_without_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=64, type_profile=ALL_STRING | 4.25 GiB/s | 11.78 GiB/s | 2.77× |
| csv_read_without_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=64, type_profile=MIXED | 3.33 GiB/s | 3.96 GiB/s | 1.19× |
| csv_read_without_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=8, type_profile=ALL_FLOAT | 3.90 GiB/s | 4.02 GiB/s | 1.03× |
| csv_read_without_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=8, type_profile=ALL_INTEGRAL | 4.30 GiB/s | 4.79 GiB/s | 1.11× |
| csv_read_without_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=8, type_profile=ALL_STRING | 8.50 GiB/s | 11.67 GiB/s | 1.37× |
| csv_read_without_inference_io | data_size_mb=256, io=HOST_BUFFER, num_cols=8, type_profile=MIXED | 4.49 GiB/s | 4.74 GiB/s | 1.06× |
| csv_read_without_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=64, type_profile=ALL_FLOAT | 9.60 GiB/s | 13.54 GiB/s | 1.41× |
| csv_read_without_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=64, type_profile=ALL_INTEGRAL | 9.67 GiB/s | 14.32 GiB/s | 1.48× |
| csv_read_without_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=64, type_profile=ALL_STRING | 5.47 GiB/s | 36.50 GiB/s | 6.67× |
| csv_read_without_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=64, type_profile=MIXED | 7.96 GiB/s | 13.50 GiB/s | 1.70× |
| csv_read_without_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=8, type_profile=ALL_FLOAT | 10.51 GiB/s | 14.34 GiB/s | 1.36× |
| csv_read_without_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=8, type_profile=ALL_INTEGRAL | 12.94 GiB/s | 17.28 GiB/s | 1.34× |
| csv_read_without_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=8, type_profile=ALL_STRING | 17.06 GiB/s | 39.10 GiB/s | 2.29× |
| csv_read_without_inference_io | data_size_mb=256, io=PINNED_BUFFER, num_cols=8, type_profile=MIXED | 12.86 GiB/s | 17.26 GiB/s | 1.34× |
CSV_READER_TYPE_INFERENCE_NVBENCH (32 configs)
| Benchmark | Config | Base | This PR | vs base |
|---|---|---|---|---|
| csv_read_with_inference | data_size_mb=256, num_cols=64, type_profile=ALL_FLOAT | 10.18 GiB/s | 25.43 GiB/s | 2.50× |
| csv_read_with_inference | data_size_mb=256, num_cols=64, type_profile=ALL_INTEGRAL | 9.10 GiB/s | 25.95 GiB/s | 2.85× |
| csv_read_with_inference | data_size_mb=256, num_cols=64, type_profile=ALL_STRING | 4.40 GiB/s | 53.74 GiB/s | 12.20× |
| csv_read_with_inference | data_size_mb=256, num_cols=64, type_profile=MIXED | 6.80 GiB/s | 26.12 GiB/s | 3.84× |
| csv_read_with_inference | data_size_mb=256, num_cols=8, type_profile=ALL_FLOAT | 12.32 GiB/s | 40.42 GiB/s | 3.28× |
| csv_read_with_inference | data_size_mb=256, num_cols=8, type_profile=ALL_INTEGRAL | 21.29 GiB/s | 59.48 GiB/s | 2.79× |
| csv_read_with_inference | data_size_mb=256, num_cols=8, type_profile=ALL_STRING | 12.00 GiB/s | 70.79 GiB/s | 5.90× |
| csv_read_with_inference | data_size_mb=256, num_cols=8, type_profile=MIXED | 21.64 GiB/s | 59.06 GiB/s | 2.73× |
| csv_read_with_inference | data_size_mb=64, num_cols=64, type_profile=ALL_FLOAT | 10.15 GiB/s | 18.58 GiB/s | 1.83× |
| csv_read_with_inference | data_size_mb=64, num_cols=64, type_profile=ALL_INTEGRAL | 8.33 GiB/s | 20.01 GiB/s | 2.40× |
| csv_read_with_inference | data_size_mb=64, num_cols=64, type_profile=ALL_STRING | 1.42 GiB/s | 31.85 GiB/s | 22.37× |
| csv_read_with_inference | data_size_mb=64, num_cols=64, type_profile=MIXED | 4.05 GiB/s | 22.13 GiB/s | 5.46× |
| csv_read_with_inference | data_size_mb=64, num_cols=8, type_profile=ALL_FLOAT | 11.10 GiB/s | 30.44 GiB/s | 2.74× |
| csv_read_with_inference | data_size_mb=64, num_cols=8, type_profile=ALL_INTEGRAL | 18.41 GiB/s | 43.05 GiB/s | 2.34× |
| csv_read_with_inference | data_size_mb=64, num_cols=8, type_profile=ALL_STRING | 5.52 GiB/s | 46.74 GiB/s | 8.47× |
| csv_read_with_inference | data_size_mb=64, num_cols=8, type_profile=MIXED | 19.86 GiB/s | 42.44 GiB/s | 2.14× |
| csv_read_without_inference | data_size_mb=256, num_cols=64, type_profile=ALL_FLOAT | 18.10 GiB/s | 49.38 GiB/s | 2.73× |
| csv_read_without_inference | data_size_mb=256, num_cols=64, type_profile=ALL_INTEGRAL | 17.51 GiB/s | 48.80 GiB/s | 2.79× |
| csv_read_without_inference | data_size_mb=256, num_cols=64, type_profile=ALL_STRING | 4.70 GiB/s | 82.06 GiB/s | 17.46× |
| csv_read_without_inference | data_size_mb=256, num_cols=64, type_profile=MIXED | 14.57 GiB/s | 50.66 GiB/s | 3.48× |
| csv_read_without_inference | data_size_mb=256, num_cols=8, type_profile=ALL_FLOAT | 21.55 GiB/s | 57.18 GiB/s | 2.65× |
| csv_read_without_inference | data_size_mb=256, num_cols=8, type_profile=ALL_INTEGRAL | 29.30 GiB/s | 95.63 GiB/s | 3.26× |
| csv_read_without_inference | data_size_mb=256, num_cols=8, type_profile=ALL_STRING | 21.92 GiB/s | 104.41 GiB/s | 4.76× |
| csv_read_without_inference | data_size_mb=256, num_cols=8, type_profile=MIXED | 29.24 GiB/s | 93.95 GiB/s | 3.21× |
| csv_read_without_inference | data_size_mb=64, num_cols=64, type_profile=ALL_FLOAT | 15.91 GiB/s | 30.38 GiB/s | 1.91× |
| csv_read_without_inference | data_size_mb=64, num_cols=64, type_profile=ALL_INTEGRAL | 14.41 GiB/s | 34.02 GiB/s | 2.36× |
| csv_read_without_inference | data_size_mb=64, num_cols=64, type_profile=ALL_STRING | 1.44 GiB/s | 47.35 GiB/s | 32.94× |
| csv_read_without_inference | data_size_mb=64, num_cols=64, type_profile=MIXED | 9.73 GiB/s | 33.36 GiB/s | 3.43× |
| csv_read_without_inference | data_size_mb=64, num_cols=8, type_profile=ALL_FLOAT | 18.58 GiB/s | 41.80 GiB/s | 2.25× |
| csv_read_without_inference | data_size_mb=64, num_cols=8, type_profile=ALL_INTEGRAL | 25.71 GiB/s | 60.84 GiB/s | 2.37× |
| csv_read_without_inference | data_size_mb=64, num_cols=8, type_profile=ALL_STRING | 7.32 GiB/s | 62.79 GiB/s | 8.57× |
| csv_read_without_inference | data_size_mb=64, num_cols=8, type_profile=MIXED | 25.23 GiB/s | 60.14 GiB/s | 2.38× |
Summary
All upstreamable CSV reader changes from the stacked draft PRs #16–#24, as one branch on top of
coral/csv-base(upstream9a8858dd3d+ the CSV benchmarks). This PR is for reviewing and benchmarking the combined result; the individual PRs are the units intended for review and merging.What's included (in stack order)
""unescape fused into the decode kernel; no zero-fill of fixed-width outputbatch_null_countbeyond 65,535 masksdatasource::supports_zero_copy_device_read()); all-zero integer overread fixWhat's intentionally excluded (from the raw automated result, branch
coral/csv-autoresearch-raw): SWAR plain-number fast paths, trie-skip flags inparse_options, the cached word reader and its duplicated helpers, kernel variants chosen by register counts, integer pre-decode during type inference, and the per-read exp10 table (measured slower on this stack).How it was produced
Extracted from a 10-hour CORAL multi-agent optimization run, then re-implemented one idea per PR to a principal-engineer standard, each reviewed by a separate reviewer agent at maximum rigor (2–3 rounds per PR), with findings addressed. Validation per PR: all CSV/JSON test suites at every commit, compute-sanitizer memcheck with exact allocations (plus racecheck/initcheck where kernels changed), mutation testing, bit-exact oracles, and differential fuzzing against the parent.
Known limitations / follow-ups are listed in each PR's description (e.g. sm_120-only validation of the staging rule and float changes, unaligned device views take a slower row pass, pre-existing reader quirks found during review).
Benchmarks for the combined result vs
coral/csv-baseacross device, pinned-host, pageable-host and file sources are posted as a comment below.🤖 Generated with Claude Code