Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions AGENTS.md

Large diffs are not rendered by default.

578 changes: 578 additions & 0 deletions HANDOFF.md

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions HANDOFF_SUGGESTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
Read it all. The gap isn't uniform, and that matters for where the drastic moves are:

| Workload | spliterator | uDSV | Gap |
| ----------------------------- | ----------- | ---- | --------------- |
| litmus in-memory | 103 | 358 | **3.5×** |
| openpayments in-memory | 78 | 125 | 1.6× |
| openpayments stream+count | 212 | 302 | 1.4× |
| gb-tuples stream+count | 127 | 266 | 2.1× |
| openpayments stream, retained | 78 | 76 | **already won** |

Per handoff §7 the residual cost is per-cell JS work (slice, normalize, push) plus layering — not scanning. uDSV is essentially at the V8 string-creation floor, so no byte-pipeline tweak closes 3.5×. Drastic means one of three things: produce cells cheaper, don't produce cells, or don't produce them on one thread. Ranked:

**1. Compiled row builders via `new Function` — highest ROI, uDSV-proven.**
`lib/formats/row-emitters.ts:102` builds objects with `zipSync` (an iterator + tuple allocation per cell) and dynamic `record[key] =`. That's why object mode is ~2× array mode. uDSV's `genToTypedRow` compiles `{ id: r[0], name: r[1] }` with constant keys so V8 sees a fixed shape. Compile per-header emitters once the header is known, for object _and_ entries mode, plus a fixed-arity array literal for the common fixed-column case. Applies to row path and windowed path alike. Expected: object mode → near array mode; entries similar; ~10–20% on arrays. Needs a no-`Function` fallback for CSP/bundlers. This is the only move with a directly comparable proof point in uDSV itself.

**2. In-memory: drop the kernel round-trip, run a fused `indexOf` loop on the string — biggest single gap.**
The 3.5× litmus gap lives here. Current path encodes the string into WASM memory, scans bytes, and slices at UTF-16 boundaries. But your own §8 probe measured JS `slice` at ~37 ns/cell — exactly uDSV's per-cell rate — because V8's `String.prototype.indexOf` is already SIMD. A fused, quote-aware, `indexOf`-driven loop over the string (uDSV's architecture, but with correct ragged-row and quote handling instead of schema inference) should land 200–300+ MiB/s in-memory. Keep the kernel path for byte sources. A few days of spike work; the risk is a per-character JS state machine sneaking in and eating the win — stay `indexOf`-driven like uDSV's quoted branch.

**3. Don't produce cells for count/aggregate work — and go parallel.**
The count adapters build full string arrays and discard them (`benchmarks/udsv/adapters/spliterator-count.ts`), which is why "count" runs at half of `TextSpliterator`'s row-scan speed (1.6s vs 2.4s on 300 MB). A scan-only count is ~187 MiB/s single-threaded; segment-parallel over `asManyWorkers`/`WorkerPool` multiplies that by cores — 3–5× past uDSV's 266 on gb-tuples, using parts that already exist and are tested. Two honesty constraints: a scan-only count is a _different workload_ than uDSV's full-parse count, so it needs its own labeled row in the table, not a silent adapter swap; and as a product feature (a true `countAsync` fast path, projection, in-worker aggregation) it's real value, not benchmark theater.

**4. Typed/columnar kernel output — the floor-breaker, and the biggest design cost.**
Handoff §9.1. As long as output is string arrays, you're bounded by V8 string creation and can at best tie uDSV. Parsing numeric columns into `Float64Array` inside the Rust kernel straight from bytes, emitting boundaries only for projected columns, never creates those strings at all — 5–10× on numeric-heavy data is plausible, and it's the one thing a byte-level kernel can do that a string-native parser structurally cannot. This is a new API surface (projection, type hints, columnar result), so it's a design doc before it's code. It's also the most defensible direction for the library's identity: byte ranges, SIMD, bounded memory.

**5. Sound schema specialization — adopt uDSV's trick without the unsoundness.**
uDSV _infers_ "no quotes" from a sample and corrupts ragged files silently. Spliterator can _prove_ it: one SIMD scan for the quote byte over the whole source (~70 ms on 386 MB at 5–6 GB/s, fusable into the streaming read per window). A proven-no-quote file unlocks: zero quote state anywhere, known column count → preallocated `new Array(n)` rows (have the kernel emit per-row cell counts, §9.3), no `normalizeCell` quote branch, compiled builders from move 1. On its own it's worth less than uDSV's numbers suggest (uDSV's _quoted_ branch also does ~266, so quote handling isn't the main cost) — it's the multiplier on moves 1–2, not a standalone fix. And the ragged-row policy stays correct per row, which is the honest counterweight already in your README.

**6. Micro, verify first:** skip `normalizeCell` when `trim: false` and no quote flag (check whether V8 already makes it free), kernel-emitted cell counts for preallocation. A few percent each; measure before landing.

Sequencing: **1 first** (days, broad, certain), then **2** (closes the worst table), then **3** (inverts the count tables with existing machinery). **4** is the only move that changes the game rather than the score — worth a design doc once 1–3 have landed and the remaining gap is measured again.

Want me to start on one of these? I'd suggest spiking #1 or #2 to validate the expected payoff before committing to the full change.
109 changes: 103 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,12 +297,12 @@ For information on all available commands, run `spliterator --help`.

The question that predicts the answer is not "how big is my file?" — it's **how much work happens per row.**

| Per-row work | What dominates | Reach for |
| ------------------------------------------------------------ | -------------- | --------------------------------------------------------------------------------------------------- |
| None — counting, segmenting, pulling a couple of fields | The scan | `Spliterator` raw byte ranges. The SIMD scanner earns its keep here (~5–6 GB/s vs ~600 MB/s for JS) |
| ~1–3 µs — `JSON.parse`, CSV → object, string normalize | The parse | Plain sequential `fromAsync`. Threads **lose** here (0.3–0.9×); JSONL runs ~0.5× of `readline` |
| Milliseconds — model inference, geocoding, crypto, image ops | Your handler | `parallelMapWorkers`, or `AsyncSpliterator.asManyWorkers` for one large file |
| I/O-bound — file fan-out, network | Latency | `parallelMap` (caller's thread). Concurrency peaks around 2–3, then **degrades** |
| Per-row work | What dominates | Reach for |
| ------------------------------------------------------------ | -------------- | ----------------------------------------------------------------------------------------------------- |
| None — counting, segmenting, pulling a couple of fields | The scan | `Spliterator` raw byte ranges. The SIMD scanner earns its keep here (~5–6 GB/s vs ~600 MB/s for JS) |
| ~1–3 µs — `JSON.parse`, CSV → object, string normalize | The parse | Plain sequential `fromAsync`. Threads **lose** here (0.3–0.9×); JSONL runs about even with `readline` |
| Milliseconds — model inference, geocoding, crypto, image ops | Your handler | `parallelMapWorkers`, or `AsyncSpliterator.asManyWorkers` for one large file |
| I/O-bound — file fan-out, network | Latency | `parallelMap` (caller's thread). Concurrency peaks around 2–3, then **degrades** |

The line worth internalizing: **the scan is almost never your bottleneck unless you aren't parsing.** Measure before adopting a parallel primitive.

Expand Down Expand Up @@ -374,6 +374,103 @@ Two things follow from workers being reused, both of them the point rather than

A pool smaller than `concurrency` bounds the real parallelism — segments queue for a worker instead of running at once, and `parallelMapWorkers` clamps to the pool's size. Dispose it when you are done, or bind it with `await using` as above.

## Benchmarks

Numbers from [uDSV's benchmark harness](https://github.com/leeoniya/uDSV/tree/main/bench), run unmodified: its
runner, its adapters for the other parsers, and its timing metric (the geometric mean of parse time over
three-second cycles, reported as MiB/s). Every parser produces arrays of strings, with no typing, trimming, or header
handling. The harness gives each parser a string already in memory; the streaming tables read the file with each
parser's own streaming API instead, retaining every row in the `(stream)` rows and discarding them in the
`(stream, count)` rows. The `(stream, row count)` rows are spliterator-only and a different workload: they count
rows without decoding columns — the scan-only path a pre-count or projection takes — so they are labeled apart
from the full-parse counts rather than compared against them.

Memory is the process's peak resident set over the whole run, less the baseline the runner reports before loading
the parser. An in-memory run therefore includes the input string, which every parser is handed alike, and a retained
run includes the result; the count rows show a parser's own working set. Bars are relative to the largest value in
their column.

| Date | Hardware | Node |
| ------- | --------------------------------------- | ------- |
| 2026-10 | AMD Ryzen 9 8945HS, 29 GB RAM, NVMe SSD | v26.2.0 |

`litmus_quoted.csv` is uDSV's generated 20-column file, every cell quoted. The other two are public datasets:
[CMS Open Payments](https://openpaymentsdata.cms.gov/) covered-recipient profiles (386 MiB, 32 columns, quoted) and
address tuples derived from
[HM Land Registry Price Paid](https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads)
(1.4 GiB, 6 columns, quoted), which only streams because it is past V8's string limit. A parser whose row count
disagreed with uDSV's by more than the harness tolerates is listed with that error rather than timed.

**litmus_quoted.csv (1.8 MiB, 10K rows), in-memory string**

| Name | Throughput (MiB/s) | Peak RSS above baseline (MiB) |
| ----------------- | -------------------------------------------- | -------------------------------------------- |
| csv-simple-parser | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 387 | ░░░░░░░░░░░░ 57 |
| uDSV | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 371 | ░░░░░░░░░░░░ 58 |
| spliterator | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 288 | ░░░░░░░░░░░░░░░░░░░░ 96 |
| but-csv | ░░░░░░░░░░░░░░░░░░░ 187 | ░░░░░░░░░░░░░░░░░░░░░░░ 112 |
| d3-dsv | ░░░░░░░░░░ 95.5 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 196 |
| PapaParse | ░░░░░░░░ 72.7 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 192 |

**litmus_quoted.csv (1.8 MiB, 10K rows), streamed from file**

| Name | Throughput (MiB/s) | Peak RSS above baseline (MiB) |
| ------------------------------------------- | -------------------------------------------- | -------------------------------------------- |
| spliterator (stream, row count) | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 715 | ░░░░ 53 |
| spliterator (stream, row count, 16 workers) | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 669 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 474 |
| uDSV (stream, count) | ░░░░░░░░░░░░░░ 249 | ░░░░░░░░░░░░░ 153 |
| uDSV (stream) | ░░░░░░░░░░░░░ 231 | ░░░░░░░░░░░░░░░░░ 202 |
| spliterator (stream, count) | ░░░░░░░░░░░ 199 | ░░░░░░░ 84 |
| spliterator (stream) | ░░░░░░░░░░ 178 | ░░░░░░░░░░░░░ 158 |
| PapaParse (stream, count) | ░░░░░ 89.2 | ░░░░░░ 72 |
| PapaParse (stream) | ░░░░ 66 | ░░░░░░░░░░░░░░░░░ 203 |

**openpayments_covered-recipient-profile_20260603.csv (386 MiB, 1.7M rows), in-memory string**

| Name | Throughput (MiB/s) | Peak RSS above baseline (MiB) |
| ----------------- | --------------------------------------------------- | ---------------------------------------------- |
| uDSV | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 163 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 3.82K |
| d3-dsv | ░░░░░░░░░░░░░░░░░░░░░░░░ 95.6 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 2.86K |
| spliterator | ░░░░░░░░░░░░░░░░░░░░░ 87.2 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 2.93K |
| PapaParse | Wrong row count! Expected: 1697026, Actual: 1697027 | |
| csv-simple-parser | Wrong row count! Expected: 1697026, Actual: 1697027 | |
| but-csv | Wrong row count! Expected: 1697026, Actual: 1697027 | |

**openpayments_covered-recipient-profile_20260603.csv (386 MiB, 1.7M rows), streamed from file**

| Name | Throughput (MiB/s) | Peak RSS above baseline (MiB) |
| ------------------------------------------- | ---------------------------------------------- | ---------------------------------------------- |
| spliterator (stream, row count, 16 workers) | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 2.01K | ░░░░░░ 575 |
| spliterator (stream, row count) | ░░░░░░░░░░░░░░░░░░░░░░ 1.09K | 33 |
| uDSV (stream, count) | ░░░░░░ 310 | ░ 140 |
| spliterator (stream, count) | ░░░░░░ 285 | ░ 90 |
| PapaParse (stream, count) | ░░░░ 193 | ░ 83 |
| spliterator (stream) | ░░ 119 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 3.42K |
| PapaParse (stream) | ░░ 93.1 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 3.75K |
| uDSV (stream) | ░ 70.8 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 3.65K |

**gb-tuples.csv (1.42 GiB), streamed from file**

| Name | Throughput (MiB/s) | Peak RSS above baseline (MiB) |
| ------------------------------------------- | -------------------------------------------- | -------------------------------------------- |
| spliterator (stream, row count, 16 workers) | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 721 | ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 532 |
| spliterator (stream, row count) | ░░░░░░░░░░░░░░░░░░░░░░ 393 | ░░░ 46 |
| uDSV (stream, count) | ░░░░░░░░░░░░░░░ 271 | ░░░░░░░░░░░░░░░ 131 |
| spliterator (stream, count) | ░░░░░░░░░░ 174 | ░░░░░░░░ 101 |
| PapaParse (stream, count) | ░░░░░░░ 118 | ░░░░░░ 75 |

CSV into arrays of strings is parse-bound, and on that work spliterator is mid-pack: uDSV generates a parser per
schema and slices one decoded string, and nothing byte-oriented will catch that. The row-count rows are what the
same engine does when cells are never produced — the byte-range scanner at SIMD speed, sequential, and then split
across worker threads with `AsyncSpliterator.segments`. Segments align on the record delimiter without regard to
quote state, so the parallel count assumes no quoted field contains a newline; both rows are verified against the
sequential parser on every dataset above. See [Choosing a primitive](#choosing-a-primitive) for where each of
those pays.

To rerun: `benchmarks/udsv/sweep.ts` drives the harness from a sibling uDSV checkout and writes
`benchmarks/udsv/results.json`; `benchmarks/udsv/render.ts` prints the tables above from it. Setup is in the header
of the sweep script.

## Under the hood

### SIMD acceleration
Expand Down
Loading
Loading