Skip to content

gh-161199: Speed up finding line endings in TextIOWrapper - #161200

Open
ptweezy wants to merge 3 commits into
python:mainfrom
ptweezy:gh-161199-textio-line-endings
Open

ptweezy wants to merge 3 commits into
python:mainfrom
ptweezy:gh-161199-textio-line-endings

Conversation

@ptweezy

@ptweezy ptweezy commented Nov 2, 2026 •

Copy link
Copy Markdown

For 1-byte strings (no character above U+00FF), three loops in Modules/_io/textio.c that look for \r and \n one character at a time now first skip ahead a size_t word at a time:

  • readline() and iteration with newline='', the mode the csv docs recommend.
  • The decoder's scan that keeps newlines up to date with newline=''. For files with only \r\n endings, as csv.writer writes, it runs on every chunk.
  • The decoder's translation of \r\n and \r to \n with the default newline=None.

2- and 4-byte strings, _PyIO_find_line_ending() and StringIO run the same code as before. The new code is in separate functions because putting the skip inside the existing loops made the 2-byte path up to 9% slower.

Benchmarks. Speedup over main reading a 24 MB file of 300,000 ASCII CSV rows (about 80 characters each) on an Intel Core i5-12400T. Each number is the median of 10 rounds of best-of-7.

gcc 15 PGO+LTO MSVC PGO
lines, newline='', \n endings 1.61x 1.35x
lines, newline='', \r\n endings 1.81x 1.57x
lines, newline=None, \r\n endings 1.29x 1.29x
csv.reader, \r\n endings 1.11x 1.11x
unchanged: lines, newline='\n' 1.01x 0.99x

Without PGO (gcc and clang -O3, MSVC Release), newline='' is 1.3x to 1.5x faster on these files. Files of 1-character lines gain little (0.97x to 1.11x). With clang -O3, newline=None and newline='\n' on \n files, which run unchanged code, were 2% to 4% slower. I can share the full results and the benchmark script.

Testing. The new test_newlines_input_long_lines reads lines of many lengths with each kind of line ending, with and without a NUL, tab, form feed, 0x0E or é in them, using newline='' and newline=None, for both the C and Python implementations. test_io and test_csv pass on gcc, clang, debug, free-threaded, ASan, UBSan, and MSVC x64 and Win32 builds. I also compared the new functions with the old loops on generated inputs, compiled for 32-bit x86 and big-endian targets as well, and compared C io with _pyio on edge cases such as \r\n split across chunks.

With newline='', the mode the csv documentation recommends, readline()
and iteration searched for '\r' or '\n' one character at a time.  So did
the newline decoder, both when updating the newlines attribute with
newline='' and when translating '\r\n' and '\r' to '\n' with the default
newline=None, which it does for every chunk that contains '\r'.

For 1-byte strings, these loops now first skip ahead one size_t word at
a time.  A cheap test skips words with no byte below 0x0E, and an exact
test finds '\r' and '\n' in the rest, so tabs do not stop the search.
On little-endian machines, ctz() gives the position, as in
find_first_nonascii() in unicodeobject.c.  The translation copies whole
words as it goes.  Each 1-byte loop is the old loop with the skip in
front of it, so line endings are handled as before.  If either of the
first two characters is '\r' or below, the old loop runs directly,
since it is fastest for short lines.

2- and 4-byte strings run the same code as before, as does
_PyIO_find_line_ending(), which StringIO also uses.
@python-cla-bot

python-cla-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

@eendebakpt

Copy link
Copy Markdown
Contributor

@ptweezy Please keep both the pr description and comments in the code concise. Also see https://devguide.python.org/getting-started/ai-tools/

@eendebakpt eendebakpt added the AI-needs-explanation AI-assisted work that needs further explanation and follow up from the contributor label Nov 2, 2026
@bedevere-app

bedevere-app Bot commented Nov 2, 2026

Copy link
Copy Markdown

Thank you for your interest in contributing to Python. This is a community-driven project, and we want it to be a welcoming place to learn and collaborate.

Contributors are responsible for reviewing and understanding what they submit, checking its claims, and being able to explain changes in their own words. This helps reviewers focus on the contribution and leaves more time to support other contributors.

This contribution appears to have been authored with AI assistance. Please explain your contribution in your own words and describe how you checked its correctness.

Please read our guidelines for using AI tools.

@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Nov 2, 2026
@ptweezy

ptweezy commented Nov 2, 2026 •

Copy link
Copy Markdown
Author

In my own words:

The prior code went one byte at a time looking for line endings. For any 1-byte string (ASCII and Latin-1 both), this patch instead puts a check in front that chews through 8 bytes (at least on a 64-bit build) per iteration using a bit-manipulation test. If none of the bytes are below 0x0E, skip the whole word. If one is, a second exact check for \n and \r runs on that word: when neither is present (like a tab) the loop skips the word and keeps going a word at a time. When one is, little-endian returns its exact position, and big-endian returns the start of the word and lets the byte loop scan those 8 bytes to find the newline. The byte loop only takes over outright when the first or second character is \r or below, which is the short-line shortcut.

readline() with newline='' ends up roughly 1.3x–1.8x faster and the newline decoder got the same word-at-a-time skip. Wider strings still enter the old loop.

How I checked correctness:

  • Ran test_io and test_csv on x64 MSVC release build
  • Compared C io with _pyio on generated texts: each line ending and each control or non-ASCII byte at every offset in lines several words long, plus random texts, read with every newline mode, several chunk sizes, read(), iteration and readline(size), and fed to the newline decoder directly. Everything matched.
  • Broke the \r check deliberately and confirmed that the new test and the comparison fail
  • Forced the big-endian fallback on x86-64 and re-ran the tests and the comparison
  • Repeated on a 32-bit (Win32) build where a word is 4 bytes
  • Ran tests and the comparison with -X dev, and under ASan and UBSan in WSL with PYTHONMALLOC=malloc

@ptweezy
ptweezy requested a review from cmaloney as a code owner November 4, 2026 02:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI-needs-explanation AI-assisted work that needs further explanation and follow up from the contributor awaiting review type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants