Skip to content

Rewrite CSV.jl internals for 1.0 - #1196

Merged
quinnj merged 274 commits into
mainfrom
codex/kernel-proveout-review
Sep 15, 2026
Merged

quinnj merged 274 commits into
mainfrom
codex/kernel-proveout-review

Conversation

@quinnj

@quinnj quinnj commented Aug 20, 2026 •

Copy link
Copy Markdown
Member

Summary

This PR replaces the CSV.jl parsing and writing internals for 1.0. A quote-aware structural index drives eager, lazy, row, chunk, transpose, and Tables.Scan reads. One column plan carries selection, types, missing-value intent, and field options. The writer renders bounded blocks in deterministic order. CSV keeps a small namespaced API and exports no names.

The rewrite uses released dependencies: Parsers 3, InlineStrings 2, Tables 1.14, DataStrings 1, and Durations 1.1. DataDecimals 1 is a weak dependency.

Implementation and behavior

  • Eager tables own their text. File, read, and Chunks copy long cells into column-owned buffers. Chunk buffers are adopted by reference. A finished eager read releases the mapped input; changing a source byte vector or rewriting the file cannot change the table. Rows and lazy remain documented views.
  • One structural index and column plan. Readers share quote handling, selection, field options, explicit type intent, and diagnostics. The vector scanner is selected automatically; fastindex=false retains the scalar reference path. Generic CPU checks cover both ARM and x86.
  • Compatible field-start quoting. A quote inside an unquoted field remains content, such as Pipe 3" long. Indexing detects this case and rebuilds under the field-start rule. Fuzz tests cover dialects and chunk boundaries.
  • Visible, structured problems. Eager readers retain CSV.problems(file) and print one summary warning by default. on_error=:collect records silently; :error throws CSV.ParseError. Chunks warns once, and Rows keeps :collect.
  • Explicit inference and missing-value contracts. Timestamps infer with nanosecond precision, widening to microsecond precision when needed for range. types=DateTime and typemap remain available. Declared Union{Missing,T} string requests retain their union. Pooling is opt-in; unquoted empty fields are missing. Decimal types are explicit and require exact parsing.
  • Shared writer kernels. Column descriptors feed one row loop, with uncommon types staged once per block. IO sinks start at their current position; writing CSV.Chunks streams under one header. RowWriter, transforms, custom quoting, date formats, and float formats preserve their existing contracts.
  • Faster string, Time, and float output. String and DataString bytes use one quote/escape policy with direct access to contiguous storage. A shared clock renderer handles Time and Timestamp without a temporary String per Time cell. A bounded exact-fraction shortcut handles short binary fractions; other floats still use Ryu. The default float notation decision no longer computes a redundant floating remainder.
  • Less duplicate code. Pointer-backed strings and byte vectors share the quote policy. Interior field starts no longer test for a row-end event that cannot occur there. These cleanups remove 31 lines relative to the separately committed writer optimization.
  • Executable documentation. Reading, writing, examples, migration, release notes, and public docstrings describe the ownership and option contracts. Examples and doctests run in the strict documentation build.

The migration guide lists the changed and removed 0.10 options. Julia 1.10 remains the minimum supported runtime.

Performance

The release benchmark pass profiles CPU and allocations for all twelve approximately 200 MiB read inputs and all seven writer inputs, each at one and eight tasks: 38 cases. The final comparison loads each revision as a normal package in two balanced fresh-process pairs, with five warm calls per case per process. The table reports the geometric mean of the paired speed ratios for ce58a6a → 35bf7d8, on an Apple M3 Max and Julia 1.12.6. Higher is faster.

Writer input 1 task 8 tasks
Numeric 1.19× 1.15×
Mixed 1.22× 1.17×
Strings 1.26× 1.32×
Quoted 1.37× 1.58×
Temporal 3.24× 4.21×
Wide 2.23× 2.02×
Longtext 1.26× 1.03×

Temporal writing removes about 1.25 GB of temporary allocation at either task count. Read timings range from 0.99× to 1.03×; no material reader gain is claimed. An unrelated one-core workload remained active, so small differences, including the threaded long-text result, are not treated as established gains.

The writer optimization is isolated in 23e7e5e; 35bf7d8 contains only the cleanup. The hosted cleanup-only comparison on AMD EPYC / Julia 1.13 covers 23 cases over four rounds: 22 minimum-run times are within 5%, and DataString writing takes 6.3% less time. Allocations are unchanged and all fingerprints match. This supports the code reduction, not a broad new throughput claim for the cleanup itself.

Exploratory in-process prototypes used a nested candidate module. A same-source control exposed substantial loading-mode bias on some small cases, so those timings are not used for the final table. The general-float check covers uniform, normal, and broad-exponent random values; a larger custom-date check matches all values and stays within 1% at both task counts.

The investigation rejected general writer-buffer reuse, an extra reader reservation pass, explicit string-capacity state, extra quote-scanner branches, and bulk-copy escaping. Their isolated wins came with throughput or memory regressions elsewhere. Worker scheduling and string ownership remain unchanged.

Validation

Validated source: 35bf7d8.

  • Julia 1.10.11 full checked-bounds suite: 330,576 / 330,576.
  • Julia 1.13.0 full checked-bounds suite: 331,022 / 331,022.
  • Aqua: 11 / 11. Trim compilation and execution: 5 / 5. Generic ARM CPU dispatch: 4 / 4.
  • Strict Documenter build passed, including doctests, public-doc checks, cross-references, and rendering.
  • All 24 read comparisons matched schemas and every column value. All 14 writer comparisons matched SHA-256 output hashes.
  • Writer parity covers every Float16 bit pattern, randomized Float32/Float64 values, fraction limits and neighboring floats, Unicode and quote boundaries, missing values, 10,000 generated Time values, custom formats, transforms, RowWriter, and threaded writes.
  • Both revisions completed all 130 public API benchmark cases twice; eighteen outliers and controls received focused follow-up checks. A nine-process missing-heavy check matched all bytes and placed the final median within 3% of the prior PR at one and eight tasks.
  • CI run 34971986741: all twelve substantive jobs passed on 35bf7d8, including Linux ARM/x86/x64, Windows, macOS ARM, Julia 1.10/current/nightly, trim, quality, and docs. The manual performance job is intentionally skipped on PR CI.
  • Hosted performance run 34972026888: passed, with an empty value/output fingerprint diff and unchanged allocations across the 23 selected cases.

Gates before a 1.0 release

  • Maintainer review of the rewritten API and implementation.
  • PkgEval and reverse-dependency review beyond the existing DataFrames probes. Packages bounded to CSV 0.10 will not select 1.0 automatically.

The version is already 1.0.0. This PR does not tag or register CSV.

Contribution disclosure

Claude authored the rewrite and review fixes. Codex contributed review fixes, latency and performance work, adversarial tests, benchmark tooling, CPU guards, and release validation.

Co-authored by Codex

quinnj and others added 30 commits August 13, 2026 01:16
Fold each task-local problem log into one globally capped reservoir as soon as the task completes. Keep chunk tags for final row rebasing, preserve deterministic source order and the exact dropped count, and release local retained entries.

Co-Authored-By: Codex <codex@openai.com>
Describe the fused probe and reparse flow, the inline-or-view KStr layout, exact segment sizing, and the allocating hash implementation accurately. Refresh the test-count and benchmark wording.

Co-Authored-By: Codex <codex@openai.com>
Tape emission (flat offsets, hygiene deferred):
- scanners now emit ONE UInt32 per structural event -- (relpos << 2) | kind --
  and nothing else; CRLF pairing, comment/empty-row hygiene, row boundaries,
  and row-start offsets all move to assemblerows!, a single pass over the
  compact tape (4 B/event ~ 1/10th of input bytes). FieldSpan, emitfield!,
  endrow!, the CRLF lastcr dance, and per-event push! all die; the hot loop's
  per-event work is one store and a cursor bump with capacity ensured once
  per 64-byte block. fieldspan() reconstructs (pos, len) in O(1) from the
  tape + per-row start offsets (rowstartrel keeps dropped rows from
  corrupting first-field starts). Index memory halves.

Vector scan engine (deliberately NOT tuned to one platform):
- scanner=:vec (the new default) builds block masks from width-generic LLVM IR
  (<64 x i8> icmp -> <64 x i1> -> bitcast i64): LLVM lowers it per HOST --
  one vpcmpeqb into a mask register on AVX-512, paired 32-byte compares +
  vpmovmskb on AVX2, compare + reduction sequences on NEON. One
  implementation covers M-series and x86; nothing is Apple-specific.
- scanner=:swar keeps the no-SIMD-assumptions fallback; :scalar remains the
  oracle; the test matrix runs all three x sequential/parallel x chunk sizes
  (7638 assertions green at -t1 and -t4).
- prefix-XOR via carry-less multiply where universal (PCLMULQDQ on x86_64,
  PMULL on Apple aarch64 -- llvm.aarch64.neon.pmull64; verified against the
  shift ladder), shift ladder elsewhere.

Single-thread structural scan (200 MiB): mixed 1.56 -> 2.37 GiB/s
(tape 1.87, +vec 2.37); long-field 2.8 -> 4.3 GiB/s -- at the practical NEON
ceiling; the AVX-512 lowering of the same IR is where the 8 GB/s-class
numbers live (pending access to such a machine). Parallel index: 15 GiB/s
mixed, 28.5 GiB/s long-field at 8 threads. Giant-single-row bound tightens
to 1 GiB (tape packs relpos in 30 bits).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Store the delimiter skip as Int so tape reconstruction supports the same multi-byte delimiter widths as the scalar scanner.

Co-Authored-By: Codex <codex@openai.com>
Validate scanner selection before empty-input returns. Pin automatic, explicit, disabled-fast-path, and scalar-fallback dispatch semantics.

Co-Authored-By: Codex <codex@openai.com>
Reject chunks of 1 GiB or more before scanning. An unterminated chunk needs one synthetic event beyond its last byte, so the previous boundary overflowed the 30-bit relative position.

Co-Authored-By: Codex <codex@openai.com>
Pin tape reserve growth, CRLF boundaries, dropped-row starts, synthetic endings, scanner plumbing, vector mask bit order, prefix-XOR equivalence, and malformed raw-byte differentials.

Co-Authored-By: Codex <codex@openai.com>
Update the kernel overview for tape assembly, vector-default scanning, the SWAR fallback, current regression count, and the new extension seams.

Co-Authored-By: Codex <codex@openai.com>
Use typed-pointer load IR for Julia 1.10's LLVM 15 parser and opaque-pointer IR on Julia 1.11 and later. Keep the same unaligned width-generic vector compare.

Co-Authored-By: Codex <codex@openai.com>
kernel/values.jl -- the new L3, built to the agreed constraints: total
functions (value + rc, no fallback to xparse/Base.parse/C ever), zero
dependencies in the core (no GMP/MPFR; the Eisel-Lemire powers-of-five table
is computed at precompile time by a minimal limb helper -- mul-by-5, shifted
compare/subtract, one restoring division -- that never escapes the builder),
bootstrap-clean (no @generated/eval/closures), strict parse-set == detect-set
spellings, and Dates-independent CivilParts + format programs with thin
Dates adapters fenced in their own module.

- parseint64: SWAR 8-digit gulps, exact 19/20-digit overflow (RC_OVERFLOW =
  the lattice Int64->Float64 promotion cue)
- parsefloat64: Clinger exact tier -> Eisel-Lemire (128-bit, table-driven) ->
  Tao simple-decimal-conversion over a fixed 800-digit buffer (768-digit
  worst-case bound makes it total; subnormals store their leading bit -- the
  classic bug, caught by the oracle suite)
- parsebool strict; findcontent/matchsentinel span utilities (unquoted
  strings need NO parsing at all)
- CivilParts/daysfromcivil (Rata Die, Hinnant) + compilepattern format
  programs (Dates token rules, unsupported tokens fail at compile time,
  English month tables as the future Dates-locale seam)

test_values.jl: ~1.2M oracle differentials -- 250k ints vs Base, 300k
shortest-repr Float64 bit-exact round-trips, 150k random decimal strings,
SDC long-mantissa pressure, pinned adversaries (min subnormal, PHP-hang
value, 768-digit halfway), every day 1900-2100 vs Dates, format-program
round-trips vs Dates.format, pinned deltas (Base throws ERANGE both
directions; this layer returns +-Inf/+-0 with OK).

valuebench.jl tier-1 micro (ns/value, M-series): ints 4-5 vs xparse 10-19;
float short 13 vs 20; date ISO 17 vs 30; datetime 27 vs 81; bool 0.3 vs 15;
string unquoted 0.6 vs 29; string quoted 7 vs 36.

KNOWN ISSUE (probe_float_anomaly.jl): full-precision floats on the EL path
pay ~175ns when the tier-3 call is reachable downstream -- survives @noinline
and an inference barrier; signature-identical dummy callee shows no penalty.
Priority item for the next review round before kernel integration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Truncated (>19 significant digit) mantissas were decided by a single-sided
ambiguity heuristic and could land one ULP off (45 oracle mismatches in 150k
random strings). Now the reference fast_float rule: Eisel-Lemire runs on both
mant and mant+1; identical results decide, disagreement falls to the exact
digit tier. Also pins the underflow half of the ERANGE delta in the
adversary set. Value suite: 1,228,714/1,228,714 against the Base/Dates
oracles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…om the runtime

Every cell now flows through cellcontent (quote/whitespace/sentinel
disposition on the raw span) + the KernelValues kernels on the content span.
detecttype and parsevalue run the SAME kernels, so parse-set ≡ detect-set by
construction and the per-value canonical-conflict guard (six prefix probes on
the hot path of every inferred Bool/temporal column) is deleted outright,
along with the xparsestring shim and its PosLen31 width workarounds — spans
are Int64/Int32 end to end.

Semantic deltas, all pinned in tests: Bool is strictly true/false (or the
user lists, which now REPLACE the defaults); temporals are pattern-exact; a
custom dateformat detects as exactly the type its components spell; a
stripped-to-empty field in a typed column is cleanly missing (the old path
materialized Date(1) and force-promoted the column to String). A bare
mid-field quote that protects a delimiter is still surfaced as a problem —
the rule is now explicit (_delimclash) instead of a Parsers span-consumption
side effect.

ValueOpts replaces Parsers.Options; examples (Chunks/Rows layers) migrate to
the same cell layer. Parsers remains only as a benchmark baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apply the standard Eisel-Lemire denormal shift instead of routing every compact subnormal through simple decimal conversion. Correct the anomaly probe to count real tier-3 entries and pin the compact subnormal route.

Co-Authored-By: Codex <codex@openai.com>
Reject Bool spellings that collide with earlier inferred types, enforce custom temporal target classes, preserve invalid structural spans in String columns, and return controlled errors for unsupported Number columns and oversized civil years. Align Eisel midpoint detection with fast_float and expand value-layer regression coverage.

Co-Authored-By: Codex <codex@openai.com>
…ring

truestrings=["1"]/falsestrings=["0"] is a common, legitimate configuration;
rejecting it at options construction (round 6) threw away real capability.
The sample-dependence hazard only exists for INFERRED columns, so a colliding
spelling now just removes Bool from the inference cascade (ValueOpts.inferbool)
while user-typed Bool columns keep parsing the lists. Deterministic in both
worlds, pinned for nsample 1 and 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
degroup! (values.jl) strips separators with digit-adjacency validation in the
integer part only; group widths stay lenient so Indian-style grouping parses.
parsevalue's numeric forms take a per-(column x chunk) scratch buffer, so the
grouped path never allocates per cell, the no-marks case costs one scan, and
groupmark=off runs the exact pre-feature code. groupmark == delim composes
through quoting naturally (the indexer already treats quoted marks as content).
Detection and parsing share the degroup path, keeping parse-set = detect-set
for grouped spellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parsebigint: strict integer grammar through 18-digit chunks flushed with
in-place GMP ops (one BigInt allocated per value; the allocating form measured
slower than mpz_set_str, this form beats it). parsebigfloat: correctly rounded
at any precision by pure integer scaling — all significant digits into a
BigInt, then x5^q (exact) or divrem against 5^-q with the remainder as the
sticky bit, one round-half-even of the top prec bits, assembled via exact
BigFloat(BigInt)+ldexp so MPFR only stores the result (mpfr_strtofr never
runs). Replaces a first-cut HPD bit-walk that was 150x slower than the oracle.
Range gate |10^(q+ndig)| <= 1e65536 returns RC_OVERFLOW (upstream form gets
power-of-ten jump tables). parseuuid: canonical dashed hex to UInt128, thin
Base.UUID adapter in core. All three are user-typed columns only — the
inference lattice is untouched.

Oracle differentials: 109k adversarial + random spans vs Base.parse
(mpfr_strtofr at 53/113/256/1000 bits, bit-exact incl. 400-digit halfway
cases), plus 20k cross-pipeline checks (parsebigfloat@53 === parsefloat64).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pow_ui/mul!/mul_2exp!/tdiv_qr! + tstbit/scan1 rounding decisions replace ~15
BigInt temporaries per value with 4: 1379 -> 630 ns/value at 256 bits
(mpfr_strtofr oracle: ~364). Suite stays bit-exact (1,338,143).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PooledColumn{KStr|Union{KStr,Missing}}: refs (0 = missing) + first-occurrence
levels as a KStrVector. Pooling is a stitch-time transformation over the
per-chunk StringColumn segments — parse loops, promotion, and re-parse
machinery are untouched, column-level stitch parallelism is preserved, and
level ids are deterministic (chunk order = row order). The intern table is
Dict{KStr,UInt32}: KStr hashing/equality are content-based and allocation-
free, so no custom table machinery. Policy pool=(ratio, cap) abandons the
moment levels exceed min(ratio*rows, cap) and falls back to the flat stitch —
a failed gamble costs one partial hashing pass, never a re-parse. Level
payloads viewing chunk-local extra buffers are copied into the pooled
column's own extra; buf-view and inline payloads pass through.

Measured (30 MiB, 2 pooled categorical cols + high-cardinality id + ints,
8T): pool=off 580 MiB/s, pool=on 613 MiB/s — within noise; the id column
abandons at the cap. pool=false (default) leaves every existing path
byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Use the full mantissa exponent for the prove-out bound and reject oversized exponents before GMP scaling. Extend differential coverage to low, odd, and boundary-shaped precisions.

Co-Authored-By: Codex <codex@openai.com>
Reject the reserved NUL marker, return the documented status for marks outside the integer part, and route grouped BigInt and BigFloat columns through reusable scratch storage. Add allocation, inference, and validation coverage.

Co-Authored-By: Codex <codex@openai.com>
Use the exact ratio bound, reject negative caps, and normalize extra-backed payload lengths before rebasing. Cover long escaped levels, fallback, promotion, determinism, empty input, and the AbstractVector contract.

Co-Authored-By: Codex <codex@openai.com>
Verify that one colliding Bool spelling disables the full inference entry without changing explicit Bool parsing across sample sizes.

Co-Authored-By: Codex <codex@openai.com>
The remaining gap to mpfr was allocation profile: three finalizer-registered
GMP objects per value (mantissa BigInt, remainder BigInt, result BigFloat)
vs mpfr's one. BigWork holds the two temporaries for reuse across a column
loop (same pattern as the groupmark scratch); a precompile-time 5^q table
(q <= 512) kills the per-value pow_ui; and assembly fuses into one in-place
mpfr_set_z + mul_2si + neg (both stores exact — the single rounding remains
ours). Base.GMP.MPZ internals are load-time guarded so a Julia that moves
them fails loudly, never silently.

At 256 bits vs Base.parse (mpfr_strtofr): 46-digit corpus 209 vs 357 ns/value
(1.7x faster; was 3.8x slower at first cut), short-mantissa corpus 101 vs 238
(2.4x faster). Workspace statelessness pinned by an interleaved-shapes
differential; full suite 1,368,401 bit-exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e driver, two-phase masked parse

Driver gains four dependency-free hooks, all plain data:
  select  — Bool/Int/Symbol/String column selection; unselected columns are
            never sampled, parsed, or stitched
  limit   — exact under any parallelism: chunks past the boundary are dropped
            before value parsing, the boundary chunk stitches a prefix, and
            problem reports beyond the limit are trimmed
  rowmask — global row filter: excluded cells are never value-parsed, never
            report problems, never pool; stitch gathers qualifying rows to
            compact positions (chunk order, deterministic); type inference
            samples only qualifying rows
  index   — reuse a prebuilt BufferIndex across parse calls

kernel/scan.jl implements the Tables.scan protocol on top (requires the
jq/scan branch of Tables.jl, dev'd into the kernel env): bind against the
extracted header, seed types from select items, exact limit/offset, and the
TWO-PHASE MASKED PARSE for filters — parse predicate columns, evaluate the
mask, parse remaining columns only where it is true. Every Scan axis is
consumed exactly, so the residual is always empty.

135 integration tests: pushdown ≅ generic Tables.finish across scan shapes,
chunk geometries, and parallelism; composition with pool (masked pooling) and
groupmark; one pinned deliberate divergence — masked inference means garbage
in excluded rows cannot degrade a qualifying column's type, which the
parse-everything-first generic path structurally cannot offer. 30 MiB demo
(temporal + pooled columns, 0.2%-selective filter): 1.6x faster, 1.5x fewer
allocations than the generic path; the multiplier scales with per-cell cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Advance compact pooled destinations across all-missing segments. Add multi-chunk serial and parallel coverage for missing, pooled, and escaped string cells.

Co-Authored-By: Codex <codex@openai.com>
Suppress row and value diagnostics after the boundary limit before they enter the bounded problem log. Preserve the source-earliest problem for deterministic on_error behavior.

Co-Authored-By: Codex <codex@openai.com>
Share header parsing with the driver, retain index-level quote problems, suppress duplicate phase-two structural reports, and enforce maxproblems once across all scan phases.

Co-Authored-By: Codex <codex@openai.com>
Expose ParsedTable row counts for zero-column filters. Type-detect limit-excluded rows so pushdown keeps full-parse inference without parsing or reporting their values.

Co-Authored-By: Codex <codex@openai.com>
Report an EOF quote problem only when the requested row limit reaches the malformed final data row.

Co-Authored-By: Codex <codex@openai.com>
This was referenced Sep 15, 2026
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.

2 participants