Skip to content

PERF: recover read_csv string-column speed after GH-66415 - #66426

Draft
jbrockmendel wants to merge 3 commits into
pandas-dev:mainfrom
jbrockmendel:perf-read_csv-strlen-depchain
Draft

PERF: recover read_csv string-column speed after GH-66415#66426
jbrockmendel wants to merge 3 commits into
pandas-dev:mainfrom
jbrockmendel:perf-read_csv-strlen-depchain

Conversation

@jbrockmendel

@jbrockmendel jbrockmendel commented Jul 22, 2026

Copy link
Copy Markdown
Member

Follow-up to GH-66415. That PR fixed read_csv's c engine truncating strings at an embedded NUL by replacing strlen with _token_len in _string_pyarrow_utf8 pass 1. _token_len reads word_starts — a second per-token metadata array, streamed alongside the words array that coliter_next_with_idx already reads — which cost up to 12% on string columns.

_token_len_words takes the boundary from words instead. The tokenizer maintains words[i] == stream + word_starts[i] at every mutation site, so the arithmetic is identical; what changes is that the loop touches one metadata array instead of two.

No whatsnew: _string_pyarrow_utf8 arrived in 3.1.0-dev via GH-65283, so neither the regression nor this fix has shipped.

Benchmarks

Full 123-case shared read_csv suite, M3 Pro, branch 3faea86 vs a baseline worktree pinned at 9828540. Adjacent-paired, adaptive 8–40 rounds to a ±0.75% target, 99% CIs, 1% effect floor. A separate worktree pinned at the same commit provides the null control column. Both build artifacts hash-verified unchanged across the run; 0 cases skipped.

Median 1.0010. 18 cases ≥3% faster, 17 more 1–3% faster, no regression ≥3%.

case speedup 99% CI n null
str @6 1.192x [1.184, 1.200] 30 0.999
str dtype_backend=pyarrow @12 1.169x [1.160, 1.177] 33 0.999
str 6-char high-card [par] 1.164x [1.157, 1.171] 30 1.002
str @12 1.164x [1.156, 1.171] 40 1.002
str low_memory [par] 1.123x [1.115, 1.131] 35 0.999
str dtype_backend=pyarrow 1.121x [1.113, 1.129] 29 1.005
high-card strings @12 1.114x [1.103, 1.125] 40 1.002
str [par] 1.113x [1.105, 1.121] 29 0.997
quoted @6 1.105x [1.097, 1.112] 30 1.000
high-card strings @6 1.098x [1.090, 1.106] 26 0.998
high-card pyarrow [par] 1.085x [1.077, 1.093] 35 1.003
high-card strings [par] 1.071x [1.064, 1.077] 40 1.002
quoted [par] 1.064x [1.056, 1.072] 11 0.996
datetime-as-str 1.054x [1.047, 1.060] 30 1.000
escaped quotes @6 1.038x [1.031, 1.045] 12 1.001
parse_dates 1.037x [1.029, 1.045] 39 1.005
non-ASCII UTF-8 [par] 1.036x [1.028, 1.044] 40 1.007
str @2 1.034x [1.027, 1.042] 33 0.998

Two small regressions

Both clear the significance bar, and the null control reproduces neither, so neither is machine noise.

case speedup 99% CI n null
int64, 3 mixed-magnitude cols [par] 0.990x [0.982, 0.997] 33 0.998
high-card strings @2 0.988x [0.981, 0.996] 40 0.999

int_mixed_mag is three pure-integer columns, so _string_pyarrow_utf8 never runs for it and the changed code cannot be executing. The plausible mechanism is code layout: this PR adds a function to parsers.pyx and shifts everything after it. A base-vs-base null control cannot detect that, since identical source produces identical layout — so the null clearing this case is weaker evidence than it looks.

high-card strings @2 is a genuine narrow regression: the same fixture wins at 1, 6 and 12 threads and under [par] (1.022x / 1.098x / 1.114x / 1.071x), and loses only at 2. Unexplained.

One further case, medium (~6 MB) @2 at 1.016x, is classified an artifact — the null control returns 1.013x on identical code, so it is that fixture's own instability rather than an effect of the branch.

Variants tried and dropped

Noted so they are not re-proposed. A hybrid helper using strlen under a 32-byte cap plus a one-compare embedded-NUL verify cost 11.6% on plain str and 10% on str @2: token length does not separate the cases it was meant to separate, since str is 4–7 byte tokens and str_nonascii 16–19, both under the cap. A prefetch variant, testing whether strlen won by warming the lines pass 2 copies, came out statistically indistinguishable from this version across all 19 string cases.

@jbrockmendel jbrockmendel added Performance Memory or execution speed performance IO CSV read_csv, to_csv labels Jul 22, 2026
@jbrockmendel jbrockmendel changed the title PERF: keep strlen on the read_csv string-length dependency chain for short tokens PERF: recover read_csv string-column speed after GH-66415 Jul 27, 2026
@jbrockmendel
jbrockmendel marked this pull request as ready for review July 27, 2026 22:13
@jbrockmendel
jbrockmendel marked this pull request as draft July 28, 2026 00:21
jbrockmendel and others added 3 commits July 28, 2026 20:59
…short tokens

GH#66415 swapped strlen for _token_len boundary arithmetic in
_string_pyarrow_utf8 pass 1, which put the boundary load's cache miss
on the offsets dependency chain and lost the scan's prefetch of the
bytes pass 2 copies; bandwidth-bound parallel reads paid ~20% on
short-token string columns. Add _token_len_checked: boundary-first,
strlen for tokens <= 32 bytes with a one-compare embedded-NUL verify,
boundary arithmetic for longer tokens (which want the O(1) form).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Supersedes the _token_len_checked helper from the previous commit,
which measured 11.6% slower than main on short-token string columns.

GH#66415 computed pass-1 token lengths from word_starts, a second
metadata array streamed alongside the words array coliter_next
already reads. Taking the boundary from words is identical
arithmetic over one array.

123-case csvbench sweep vs main: median 1.0018, 30 cases >=1%
faster (top 1.113x), no confirmed regression.

Also points the embedded-NUL test at GH#19886, the issue it
actually covers, and drops an unsound dtype=object oracle: the C
object path interns words by their NUL-terminated prefix, so it
is not a valid reference for NUL-bearing data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment-only follow-ups from reviewing the branch:

- The embedded-NUL comment and test cited GH#66277, the perf issue, and
  briefly GH#19886, which this branch does not fix (three of that issue's
  four reported cases still fail). Both now cite GH#66415, matching the
  whatsnew entry for the fix.
- The helper's precondition named coliter_next; its only caller uses
  coliter_next_with_idx, so the precondition is now checkable.
- The test comment explained the wrong thing about the two columns: "a"
  covers the words[token_idx + 1] branch and "b" the stream-end branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jbrockmendel
jbrockmendel force-pushed the perf-read_csv-strlen-depchain branch from 72f2d91 to 3faea86 Compare July 29, 2026 14:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

IO CSV read_csv, to_csv Performance Memory or execution speed performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant