Skip to content

perf: CSV import ~1.5x faster (pipelined, byte-level parse, UTF-8 bytes into the writer) - #590

Merged
dfa1 merged 4 commits into
mainfrom
perf/csv-import-compact
Oct 22, 2026
Merged

dfa1 merged 4 commits into
mainfrom
perf/csv-import-compact

Conversation

@dfa1

@dfa1 dfa1 commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Profiled a 2.7 GB / 45M-row import --compact (YourKit + JFR with DebugNonSafepoints) and fixed what showed up. Separate commits, rebase-merge.

Result: original → this PR, 4 interleaved runs on a loaded machine: 15.65 s → 10.71 s (−32%), 583G → 520G instructions. Quiet machine: ~10.6 s → ~6.5 s. Output is byte-identical to main throughout (sha of the 614 MB file unchanged).

  • perf(csv): parse the next chunk while a background thread converts and writes the previous one.
  • perf(csv): CsvByteReader parses bytes into a column-wise ColumnChunk (no char decode, no String per field); numbers parse in place, strings go to the writer as UTF-8. Same format as FastCSV's defaults — CsvByteReaderTest checks 200 random files (quotes, "", every line ending, empty lines, multi-byte and malformed UTF-8, BOM, 1–7-byte reads) against FastCSV itself. Only difference: the delimiter must be ASCII.
  • feat(writer): writeChunk accepts a Utf8 column as UTF-8 byte[][] (validated, Utf8Validation, RFC 3629). Encoders tell Utf8 from Binary by dtype instead of carrier. Utf8BytesInputTest: 5 column shapes × 3 option sets written both ways → byte-identical files.
  • fix(writer): Utf8 dict bounds used String::compareTo (UTF-16 order) instead of UTF-8 byte order; reached files only via the legacy vortex.stats layout. No vortex-jni cross-check added for this one (needs a legacy-edition filtered read); unit test covers the order.
  • perf(writer): Zstd string stats from the bytes it already encoded; zone min/max as primitives; Repartitioner sizes strings with getBytes(UTF_8).length.
  • docs: CLAUDE.md bans ternaries that wrap or bind a pattern.

Tried and dropped: pooling zstd contexts, regrouping fields at parse time, and parallel block parsing — correct (speculative split + verification, byte-identical) but slower on a 4P+6E laptop: the machine is CPU-bound, so parse threads take CPU from the encoders (kept on a local spike/parallel-csv-parse branch for many-core machines).

./mvnw verify -pl core,writer,csv,cli,integration -am green (3025 integration tests).

🤖 Generated with Claude Code

@dfa1
dfa1 force-pushed the perf/csv-import-compact branch 2 times, most recently from d727bfa to 12dc3ef Compare October 22, 2026 20:06
@dfa1 dfa1 changed the title perf: faster CSV import (pipelined parse, cheaper writer stats) perf: CSV import ~1.5x faster (pipelined, byte-level parse, UTF-8 bytes into the writer) Oct 22, 2026
@dfa1
dfa1 force-pushed the perf/csv-import-compact branch from 877e5a3 to e6541b5 Compare October 22, 2026 20:53
dfa1 and others added 4 commits October 22, 2026 22:55
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DictEncodingEncoder picked a string column's min/max with String::compareTo,
UTF-16 order, where every other string bound in the writer uses UTF-8 byte
order (Utf8Order), as Rust compares strings. A dictionary mixing a
supplementary character (a surrogate pair in UTF-16) with U+E000..U+FFFF
reported the bounds the wrong way round. The default vortex.zoned layout
computes its own bounds, so this reached files only through the encoder
stats (the legacy vortex.stats layout).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
writeChunk takes a Utf8 column as byte[][] as well as String[]: a caller that
already holds UTF-8 (a CSV parser) skips decoding to String only for every
encoder to encode it back. Rows are checked to be well-formed UTF-8
(Utf8Validation, RFC 3629, as Rust validates a Utf8 array).

The encoders told Utf8 from Binary by carrier (String[] vs byte[][]); they now
ask the dtype, through VarBinEncodingEncoder.textMinMax for the bounds, and
Dict and Constant key and compare the bytes directly. The global dictionary
keys Utf8 by String either way, so a column mixing both carriers codes each
value once. Utf8BytesInputTest writes 5 column shapes x 3 option sets both
ways and asserts byte-identical files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Profiled a 2.7 GB / 45M-row CSV import (YourKit, JFR with DebugNonSafepoints)
and fixed what showed up. Output is byte-identical in default and compact modes.

CSV import:
- Parse the next chunk while a background thread converts and writes the
  previous one: parsing is the import's one sequential step.
- CsvByteReader parses bytes into a column-wise ColumnChunk instead of
  FastCSV's chars and a String per field, and Utf8 columns reach the writer
  as UTF-8 byte[][]. Same format as FastCSV's defaults: CsvByteReaderTest
  reads 200 random files (quotes, "", every line ending, empty lines,
  multi-byte and malformed UTF-8, a BOM, 1-7-byte reads) against FastCSV.
  The delimiter must now be ASCII.
- Numbers parse in place; a plain decimal whose digits fit 2^53 is
  digits / 10^k, bit-identical to Double.parseDouble (Clinger's fast path,
  20 000 random decimals checked). A string column is checked for
  non-ASCII once per chunk, eight bytes at a time.

Writer (CPU the profile pinned on it):
- Zstd orders string zone stats by the bytes it already encoded (6.9 s ->
  1.2 s of a compact import) instead of comparing Strings again.
- Zone min/max kept as primitives: an ascending id boxed a Long per row.
- Repartitioner sizes strings with getBytes(UTF_8).length, twice as fast
  as the hand-written count and exact by definition.
- OnPair's first two trie levels are direct-indexed and deeper edges sit in
  a one-array table: it wins high-cardinality strings by default and its
  per-byte hashing was 38% of a default import.

2.7 GB CSV, interleaved runs: default 13.13 s -> 7.69 s, compact (loaded
machine) 15.65 s -> 10.71 s. Tried and dropped: pooled zstd contexts,
regrouping fields at parse time, and parallel block parsing (correct, but
slower on a CPU-bound 10-core laptop).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dfa1
dfa1 force-pushed the perf/csv-import-compact branch from e6541b5 to dbae512 Compare October 22, 2026 20:58
@dfa1
dfa1 merged commit 3ecd86e into main Oct 22, 2026
6 checks passed
@dfa1
dfa1 deleted the perf/csv-import-compact branch October 22, 2026 21:06
dfa1 added a commit that referenced this pull request Oct 22, 2026
Cut the Unreleased entries into 0.18.1 (2026-10-22) and list the dict bounds
fix from #590 under Fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant