Skip to content

Speed up CSV parsing: windowed cell scans, batched sequences, compiled row builders - #27

Merged
GirlBossRush merged 10 commits into
mainfrom
perf/csv-cell-path
Oct 9, 2026
Merged

GirlBossRush merged 10 commits into
mainfrom
perf/csv-cell-path

Conversation

@GirlBossRush

Copy link
Copy Markdown
Member

Summary

A performance arc over CSV (and JSONL/text) parsing, benchmarked against uDSV's own harness, plus the benchmark integration itself. Full narrative in HANDOFF.md (sections 3.1–3.14); all measurements reproducible via benchmarks/udsv/sweep.ts.

  • AsyncSequence pull loop: no longer awaits the opener on every item (20–40% on streamed rows).
  • Batched sources (BATCHED_SOURCE): rows cross the sequence boundary in bulk.
  • Windowed cell scanning: AsyncSpliterator.nextWindow() + the WASM cell kernel on the streaming CSV path.
  • Scavenger fix: one slice per cell and exact-width row templates — the in-memory litmus gap was GC, not parsing (103 → ~210+ MiB/s).
  • Compiled row builders (new Function, CSP-safe fallback): object mode is now free relative to array mode (62.5 → 10.0 ms on litmus).
  • Stepping cell scanner and kernel batch returned as a shared-memory view.
  • Windowed decode for streamed text/JSONL: JSONL streaming now matches readline + JSON.parse (707 vs 702 ms on 600k rows); 500MB corpus 1948 vs 2092 ms.
  • String sources split as text: TextSpliterator.from(string) 7.8µs → 2.5µs on a 40-line string.
  • import.meta.resolve fix for jiti/Docusaurus consumers (was slated as 9.1.1).
  • uDSV benchmark harness integration with honest README tables, now including scan-only and worker-parallel row-count rows (sequential 715/1091/393 MiB/s, 16 workers 669/2015/721, vs uDSV's full-parse count 249/310/271 on litmus/openpayments/gb-tuples). Parallel counting is verified exact on all three datasets; its quote-blind segmentation limit is demonstrated and documented.

Test plan

  • yarn compile && yarn test --run && yarn lint green (835 tests, 53 files)
  • Row-count adapters verified against countAsync and uDSV expected.json on all datasets, plus 7 edge cases (empty file, CRLF, unterminated final row, escaped quotes)
  • Parallel counter demonstrated to miscount on synthetic embedded-newline CSV — caveat carried in adapter header and README

benchmarks/udsv/sweep.ts drives uDSV's bench/runone.cjs unmodified over spliterator and the
parsers it ships adapters for, measures peak RSS through GNU time, and writes results.json;
render.ts prints Markdown tables with throughput and memory columns. The README section is
generated from it and kept honest, losses included.

Claude-Session: https://claude.ai/code/session_01MonKZmMeL1Jvo7i6EHmbYx
…wer allocations

- AsyncSequence.next() only awaits the opener on the first pull; the per-pull await was an async
  frame per row on every streamed format (20-40%).
- Batched sources: an upstream marked BATCHED_SOURCE yields arrays walked without an await per
  element.
- AsyncSpliterator.nextWindow() hands out every queued record as one contiguous view, and the
  streaming CSV path runs the cell kernel over each window, falling back per window on invalid
  UTF-8. The streaming branch awaits the scanner so a fresh process takes it on its first file.
- A string source is the decoded text: no second copy of the source, cells reference the caller's
  string.
- The kernel emits CELL_FLAG_HAS_ESCAPE when a quote opens directly after a quote, carried across
  windows; the cell loop slices a quoted cell once, inside the quotes, and only unescapes on the
  flag. The row-path walk unquotes as it splits.
- Rows are filled into exact-width template copies instead of grown by push. With rows retained
  the path was scavenger-bound: 23MB allocated per 2MB parse against uDSV's 8.5MB.

Measured: 300MB quoted stream 4.2s -> 2.1s, unquoted 3.2s -> 1.9s, 386MB in-memory string
4.2s -> 3.2s at 2237 -> 1321 MiB peak, litmus in-memory 103 -> 209 MiB/s on uDSV's harness.

Claude-Session: https://claude.ai/code/session_01MonKZmMeL1Jvo7i6EHmbYx
Replace the zipSync iterator with an indexed loop when both arguments are arrays, then compile a
per-header literal builder with new Function and cache it on the bound header array. Object mode
62ms -> 10ms on uDSV's 10k x 20 litmus fixture, the same as array mode; entries 38ms -> 17ms. A
Function constructor that throws under CSP leaves the loop in place. bindTransformers reads own
properties only; a header named __proto__ used to bind Object.prototype as its transformer.

Claude-Session: https://claude.ai/code/session_01MonKZmMeL1Jvo7i6EHmbYx
CellRowScanner.nextBatch(out) pushes one kernel batch of rows into the caller's array. The
in-memory parser yields a fresh array per batch and walks it in its own generator, so a row
crosses one generator rather than two; the streaming path collects a whole engine window into
one array with no flattening copy. litmus in-memory 9.7ms -> 8.3ms, 300MB quoted stream
2.1s -> 1.9s.

Claude-Session: https://claude.ai/code/session_01MonKZmMeL1Jvo7i6EHmbYx
TextSpliterator.fromAsync and JSONSpliterator.fromAsync hand a streamed engine to windowedTextRows,
which decodes each window once, splits the text on the delimiter (crlf and skipEmpty applied there),
and yields the rows as a batched source. One decode and one await per high-water mark of rows
instead of one of each per row. drop and take become sequence ops on these paths. A fatal decode
failure falls back to per-row decoding so the error names the row; a text comment filter mirrors
the byte one. 600k JSONL rows: 1289ms -> 707ms, level with readline + JSON.parse (702ms); a 500MB
corpus file 1948ms against readline's 2092ms.

Claude-Session: https://claude.ai/code/session_01MonKZmMeL1Jvo7i6EHmbYx
CharacterSequence.scanCells returns the result block as a window-relative view with a unitBase
instead of copying it out and rebasing in a JS loop. CellRowScanner.nextBatch turns the whole
batch into rows before returning and runs no user code, so nothing can scan in between. 386MB
streamed into objects 1869ms -> 1685ms, litmus in-memory 8.3ms -> 7.4ms, 300MB quoted stream
1.9s -> 1.8s.

Claude-Session: https://claude.ai/code/session_01MonKZmMeL1Jvo7i6EHmbYx
jiti, which Docusaurus loads its plugins through, transpiles an ESM dependency to CommonJS on the
fly: it rewrites import.meta.url and leaves import.meta.resolve in place, and Node then refuses the
file with "Cannot use 'import.meta' outside a module". 9.1.0 shipped that form in
lib/internal/node-modules.js and broke a consumer's docs build. workerEntryUrl now resolves through
createRequire(import.meta.url), which honours the # import map the same way, with the built-ins
loaded through process.getBuiltinModule so the root keeps no static node: import. A test over the
compiled output refuses any import.meta form but .url under out/lib and out/node.

Claude-Session: https://claude.ai/code/session_01MonKZmMeL1Jvo7i6EHmbYx
…tor.from

A string is already text, so encoding it, scanning the bytes and decoding each row back was pure
overhead: 7.8us per call on a 40-line string against 1us for split. textRows splits the string on
the delimiter text with the engine's crlf, skipEmpty, drop and take semantics, 2.5us per call.
Gated by canSplitAsText: no byte position, no quote handling, UTF-8 only. The streaming window
path shares the splitter. Parity with the byte route is pinned across delimiters and options.

Claude-Session: https://claude.ai/code/session_01MonKZmMeL1Jvo7i6EHmbYx
The count adapters full-parse because uDSV's do; the library's scan-only
path was never shown. Two new adapters, labeled as their own rows so the
full-parse counts stay for apples-to-apples:

- spliterator (stream, row count): CSVSpliterator.countAsync, counting
  engine rows without decoding columns.
- spliterator (stream, row count, N workers): AsyncSpliterator.segments
  plus one persistent worker per core, each counting rows in its own
  range with countAsync's semantics and posting a single number back.

Verified exact agreement with the sequential parser and expected.json on
all three datasets. Segments are quote-blind, so embedded newlines in
quoted fields would miscount in parallel; demonstrated on a synthetic
file and documented in the adapter, README, and HANDOFF.

715/1091/393 MiB/s sequential and 669/2015/721 parallel against uDSV's
249/310/271 on litmus, openpayments, and gb-tuples. README tables and
results.json regenerated from one fresh sweep; HANDOFF.md section 3.14
covers the work.
@GirlBossRush
GirlBossRush merged commit 8623f7e into main Oct 9, 2026
1 check passed
@GirlBossRush
GirlBossRush deleted the perf/csv-cell-path branch October 9, 2026 16:25
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