Parallelize large-file CSV loading (13-20x), and fix dialect guessing on files with JSON columns - #34
Open
quocson95 wants to merge 2 commits into
Open
Parallelize large-file CSV loading (13-20x), and fix dialect guessing on files with JSON columns#34quocson95 wants to merge 2 commits into
quocson95 wants to merge 2 commits into
Conversation
added 2 commits
August 26, 2026 19:19
Opening a large CSV pinned one core and froze the window until it finished. This implements steps 0-8 of docs/dev/parallel-loading-plan.md: the load now saturates all cores, the window keeps painting, and Cancel works. Measured on an M4 Pro (8P+4E), Release -O2, best of three: 117 MB, 6 cols, 15% quoted 1.397 s -> 0.097 s 14.4x 111 MB TSV, no quotes 1.350 s -> 0.123 s 11.0x 156 MB, 1000 cols 1.501 s -> 0.082 s 18.3x The post-load freeze on wide tables is gone too: arrangeColumns walked each row once per column, which took 11.6 s on a 1000-column table and ran after the progress window had already been hidden. It is now 35 ms, and inside the progress window. How it works ------------ csvfsm.hh holds the one state machine every consumer runs -- the serial parser, the chunk prescan and the parallel parse -- so the transitions cannot drift apart. At a physical line start the machine's structural state is exactly one bit (`enclosed`), so each chunk is prescanned under both carry-in hypotheses and the true carry chain resolved serially afterwards. No guessing, no verify round, no probabilistic fallback. The file is mapped once and every consumer reads the same bytes: dialect guessing, encoding detection and the parse. That removes eight seekg(0) re-reads and a full extra streaming pass. UTF-8 validation is multi-threaded and exactly equivalent to utf8::is_valid, which is what proves fixUtf8() is the identity -- the gate the parallel path needs to be provably exact. Behaviour changes worth a changelog entry ----------------------------------------- - Windows: a 0x1A (Ctrl-Z) byte no longer silently truncates the table. - UTF-8 files over 200 MB now open without the "choose your format" modal. Bugs found and fixed along the way ---------------------------------- - getNextCodeUnit() read uninitialised stack when EOF cut a UTF-16 code unit short; the garbage could equal the 0x000A line terminator. - ENC_NONE files fell into the fixed-width code-unit reader and parsed as an empty table. - A UTF-16LE file starting with U+xx00 (much of CJK) matched the UTF-32LE BOM test, which compared octet[2] twice, and opened as an empty table. - guessEncoding() read uninitialised octets for files under 4 bytes. - Loading on a background thread exposed a redraw racing the storage rebuild, a cancel that could report success after truncating, and an unguarded bad_alloc that would have called std::terminate. Testing ------- There were no tests. tests/run_all.sh now runs a headless harness that links without FLTK -- itself the forcing function that keeps the data layer decoupled: goldens over a generated corpus, istream-vs-mmap and serial-vs-parallel differential diffs, a forced chunk boundary at every byte offset of every corpus file, 150k random-input fuzz comparisons, and checks that the parallel validator equals utf8::is_valid and that parseCsvStream keeps no state between calls. Clean under ASan+UBSan and TSan.
A CSV with a JSON payload column opened with 87 columns instead of 13, silently
-- no format dialog, because the guesser was confident.
id,tenant_id,...,req_body,resp_body,...
5226,101,...,"{""user"": {""id"": ""566674135""}}",...
The JSON is correctly RFC-quoted and the parser handles it fine; the problem is
that guessDefinition also probes ':' as a delimiter. The JSON is dense with
colons, so every row splits into the same 87 pieces -- consistently, which makes
the variance metric a perfect zero. The tie-break was "more columns wins", so
':' beat ',' 60 to 13, and zero variance kept the confidence at 1.00 so the
"choose your format" dialog never appeared.
tableStatistics() skips row 0, so nothing noticed that ':' leaves the header as
a single field while exploding every data row. Two tie-breakers now sit after
variance and before the column count:
- headerMismatch: row 0 holds a different number of fields than the rest.
Well-formed CSV agrees across every row, header or not.
- orphanPercent: share of fields left holding an odd number of quote
characters, i.e. the delimiter is cutting through quoted content.
Both only ever break a tie, so a candidate that uniquely explains the row
lengths cannot be displaced. Over the test corpus 3 of 61 files change dialect,
all three to the correct one -- including bs_escaped_delim, which now finds the
backslash escape it actually uses.
The guessing code moves out of CsvApplication into src/csvguess.{hh,cpp}. None
of it ever touched FLTK; it sat in the UI layer by accident, which is why
something that silently decides how every file opens had no test. --guess-check
goldens the guessed dialect and encoding for every corpus file, and the new
json_in_column / json_semicolon fixtures reproduce the bug: both guessed COLON
at confidence 1.00 before this change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Opening a large CSV pinned a single core and froze the window until it finished.
This makes the load saturate all cores, keeps the window painting, and adds a
working Cancel. It also fixes a pre-existing bug that made any file with a JSON
column open with the wrong number of columns, silently.
Numbers
M4 Pro (8P + 4E), Release
-O2, best of three. "before" is a harness built fromthe pre-change sources — it reproduces every golden, which is what makes the
comparison trustworthy rather than self-referential.
arrangeColumns, 1000 colsThat last row was the post-load freeze: the column-width scan walked each row
once per column, and ran after the progress window had already been hidden.
How it works
csvfsm.hhholds the one state machine every consumer runs — the serial parser,the chunk prescan, and the parallel parse — so the transitions cannot drift
apart. The design turns on a single observation: at a physical line start the
parser's structural state is exactly one bit (
enclosed). A one-bit transferfunction composes in O(k), so each chunk is prescanned under both carry-in
hypotheses and the true carry chain is resolved serially afterwards. No guessing,
no verify round, no probabilistic fallback.
The file is mapped once and every consumer reads the same bytes — dialect
guessing, encoding detection, and the parse — removing eight
seekg(0)re-readsand a full extra streaming pass. UTF-8 validation is multi-threaded and provably
equivalent to
utf8::is_valid, which is what licenses skippingfixUtf8()onthe parallel path.
Workers never touch a widget or call
Fl::check(), so FLTK stays effectivelysingle-threaded: no thread-safe build, no
Fl::lock, noFl::awake.The JSON bug (second commit)
A row like
5226,101,...,"{""user"": {""id"": ""566674135""}}",...is 13comma-separated fields.
guessDefinitionalso probes:, and because JSON isdense with colons every row split into the same 87 pieces — consistently, so
the variance metric read as a perfect zero. The tie-break was "more columns
wins", so
:beat,60 to 13. Zero variance also kept confidence at 1.00,so the format dialog never appeared and the file silently opened with 87 columns.
tableStatistics()skips row 0, so nothing noticed that:left the header asone field while exploding every data row. Two tie-breakers now sit after
variance and before the column count: header consistency, and the share of
fields left holding an orphaned quote. Both only ever break a tie, so no
candidate that uniquely explains the row lengths can be displaced.
Blast radius across the 61-file corpus: 3 files change dialect, all three to
the correct one — including
bs_escaped_delim, which now finds the backslashescape it actually uses.
Behaviour changes that need a changelog line
0x1A(Ctrl-Z) byte no longer silently truncates the table.The mapping reads raw bytes; such files now open in full.
Bugs found and fixed on the way
getNextCodeUnit()read uninitialised stack when EOF cut a UTF-16 code unitshort — the garbage could equal the
0x000Aline terminator, so UTF-16 linesplitting depended on stack contents.
ENC_NONEfiles fell into the fixed-width code-unit reader and parsed as anempty table.
一and much of CJK) matched theUTF-32LE BOM test, which compared
octet[2]twice, and opened empty.guessEncoding()read uninitialised octets for files under 4 bytes.Fl::check()inside astd::sortcomparator (latent UB) — replaced with aninjected progress callback.
the storage rebuild, a Cancel that could report success after truncating, and
an unguarded
bad_allocthat would have calledstd::terminate.Testing
There were no tests before this.
tests/run_all.shruns a headless harness thatlinks without FLTK — itself the forcing function that keeps the data layer
decoupled from the UI:
utf8::is_validacross 17 thread countsparseCsvStreamkeeps no state between callsClean under ASan+UBSan and TSan. The corpus is generated (
tests/gen_corpus.sh)and gitignored.
Where to focus review
src/csvfsm.hh— the shared state machine. The transitions reproduce thehistorical parser exactly, quirks included; the comments say which and why.
src/csvloader.cpp— the prescan/compose/parse/assemble pipeline.src/csvguess.cpp— the ranking change, the only behaviour change to howexisting files are interpreted.
Risk and revert
The parallel path is gated in one place (
CsvLoader::planLoad) and refusesanything it cannot prove exact: non-UTF-8 encodings, invalid UTF-8, non-ASCII
structural characters, probing paths.
TCRUNCHER_DISABLE_PARALLEL=1forces theserial path at runtime — that is the switch to tell a bug reporter to flip. Even
with it set, the file still loads ~3.4× faster than before.