Skip to content

Commit fe2e3b6

Browse files
committed
gh-161199: Speed up finding line endings in TextIOWrapper
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.
1 parent e4dba41 commit fe2e3b6

3 files changed

Lines changed: 239 additions & 6 deletions

File tree

‎Lib/test/test_io/test_textio.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,28 @@ def test_newlines_input(self):
459459
txt.seek(0)
460460
self.assertEqual(txt.read(), "".join(expected))
461461

462+
def test_newlines_input_long_lines(self):
463+
# Lines from a few characters to many machine words long, with and
464+
# without control or non-ASCII characters in them, read with and
465+
# without newline translation.
466+
lengths = [*range(20), *range(248, 272), 600]
467+
for end in ("\n", "\r", "\r\n"):
468+
for ctrl in ("", "\x00", "\t", "\x0c", "\x0e", "\xe9"):
469+
lines = ["a" * n + ctrl + "b" * (n % 9) + end
470+
for n in lengths]
471+
data = "".join(lines).encode("latin-1")
472+
for newline in ("", None):
473+
if newline is None:
474+
expected = [line[:-len(end)] + "\n" for line in lines]
475+
else:
476+
expected = lines
477+
with self.subTest(end=end, ctrl=ctrl, newline=newline):
478+
txt = self.TextIOWrapper(self.BytesIO(data),
479+
encoding="latin-1",
480+
newline=newline)
481+
self.assertEqual(txt.readlines(), expected)
482+
self.assertEqual(txt.newlines, end)
483+
462484
def test_newlines_output(self):
463485
testdict = {
464486
"": b"AAA\nBBB\nCCC\nX\rY\r\nZ",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Speed up :class:`io.TextIOWrapper` for text with no characters above U+00FF
2+
when reading lines with ``newline=''`` (the mode recommended for :mod:`csv`)
3+
and when translating ``\r\n`` and ``\r`` line endings with the default
4+
``newline=None``. Patch by Parker Loflin.

‎Modules/_io/textio.c‎

Lines changed: 213 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,194 @@ check_decoded(PyObject *decoded)
324324
#define SEEN_CRLF 4
325325
#define SEEN_ALL (SEEN_CR | SEEN_LF | SEEN_CRLF)
326326

327+
/* Count trailing zero bits; v must not be 0. As in unicodeobject.c. */
328+
#if defined(__clang__) || defined(__GNUC__)
329+
# define HAVE_CTZ 1
330+
static inline unsigned int
331+
ctz(size_t v)
332+
{
333+
return __builtin_ctzll((unsigned long long)v);
334+
}
335+
#elif defined(_MSC_VER)
336+
# define HAVE_CTZ 1
337+
static inline unsigned int
338+
ctz(size_t v)
339+
{
340+
unsigned long pos;
341+
# if SIZEOF_SIZE_T == 4
342+
_BitScanForward(&pos, v);
343+
# else
344+
_BitScanForward64(&pos, v);
345+
# endif
346+
return pos;
347+
}
348+
#else
349+
# define HAVE_CTZ 0
350+
#endif
351+
352+
/* Skip a size_t word at a time towards the first '\r' or '\n' in the
353+
NUL-terminated UCS1 buffer [s, end). Return a pointer to it, or to an
354+
earlier character from which the caller's own loop finds it.
355+
356+
If out is not NULL, also copy the skipped characters to out. out must
357+
be no further into its buffer than s is into the input, and its buffer
358+
must have room for the whole input. Whole words are copied, so up to
359+
sizeof(size_t) bytes past the skipped characters may also be written;
360+
the caller overwrites them or leaves them past the end of its result.
361+
362+
Lines are often short, and the caller's loop is fastest for them, so if
363+
either of the first two characters is '\r' or below, return a pointer
364+
to it at once. Most words of text have no byte below 0x0E and cost one
365+
cheap test; the rest are checked for '\r' and '\n' exactly, so tabs and
366+
other control characters do not stop the search.
367+
368+
(x - ones * n) & ~x & high flags the bytes of x below n. The cheap test
369+
uses n = 0x0E; the exact test uses n = 1 on x = w ^ (ones * '\n') and
370+
x = w ^ (ones * '\r'), whose zero bytes are the matches. Borrows can
371+
flag extra bytes, but only above a byte below n, so the lowest flagged
372+
byte is always correct: on little-endian machines, ctz() gives the
373+
first match; elsewhere, the caller finds it in the word. */
374+
static inline const char *
375+
skip_to_newline_ucs1(const char *s, const char *end, char *out)
376+
{
377+
const size_t ones = (size_t)-1 / 0xFF; /* 0x01 in every byte */
378+
const size_t high = ones * 0x80;
379+
380+
/* s[1] is only read when s[0] is not the terminating NUL. */
381+
if ((unsigned char)s[0] <= '\r') {
382+
return s;
383+
}
384+
if ((unsigned char)s[1] <= '\r') {
385+
if (out != NULL) {
386+
out[0] = s[0];
387+
}
388+
return s + 1;
389+
}
390+
while (end - s >= (Py_ssize_t)sizeof(size_t)) {
391+
size_t w;
392+
memcpy(&w, s, sizeof(w));
393+
if (out != NULL) {
394+
memcpy(out, &w, sizeof(w));
395+
out += sizeof(w);
396+
}
397+
if ((w - ones * 0x0E) & ~w & high) {
398+
size_t lf = w ^ (ones * '\n');
399+
size_t cr = w ^ (ones * '\r');
400+
size_t m = (((lf - ones) & ~lf) | ((cr - ones) & ~cr)) & high;
401+
if (m != 0) {
402+
#if PY_LITTLE_ENDIAN && HAVE_CTZ
403+
return s + ctz(m) / 8;
404+
#else
405+
return s;
406+
#endif
407+
}
408+
}
409+
s += sizeof(w);
410+
}
411+
return s;
412+
}
413+
414+
/* The universal newlines search of _PyIO_find_line_ending(), for the
415+
NUL-terminated 1-byte string [start, end). */
416+
static Py_ssize_t
417+
find_line_ending_ucs1(const char *start, const char *end,
418+
Py_ssize_t *consumed)
419+
{
420+
const char *s = start;
421+
for (;;) {
422+
unsigned char ch;
423+
s = skip_to_newline_ucs1(s, end, NULL);
424+
while ((unsigned char)*s > '\r') {
425+
s++;
426+
}
427+
if (s >= end) {
428+
*consumed = end - start;
429+
return -1;
430+
}
431+
ch = (unsigned char)*s++;
432+
if (ch == '\n') {
433+
return s - start;
434+
}
435+
if (ch == '\r') {
436+
return *s == '\n' ? s - start + 1 : s - start;
437+
}
438+
}
439+
}
440+
441+
/* The newlines scan of _PyIncrementalNewlineDecoder_decode() below, for
442+
the NUL-terminated 1-byte string [s, s + len), len > 0. */
443+
static Py_NO_INLINE int
444+
scan_newlines_ucs1(const char *s, Py_ssize_t len, int seennl)
445+
{
446+
const char *end = s + len;
447+
for (;;) {
448+
unsigned char c;
449+
s = skip_to_newline_ucs1(s, end, NULL);
450+
while ((unsigned char)*s > '\r') {
451+
s++;
452+
}
453+
c = (unsigned char)*s++;
454+
if (c == '\n') {
455+
seennl |= SEEN_LF;
456+
}
457+
else if (c == '\r') {
458+
if (*s == '\n') {
459+
seennl |= SEEN_CRLF;
460+
s++;
461+
}
462+
else {
463+
seennl |= SEEN_CR;
464+
}
465+
}
466+
if (s >= end || seennl == SEEN_ALL) {
467+
return seennl;
468+
}
469+
}
470+
}
471+
472+
/* The translation loop of _PyIncrementalNewlineDecoder_decode() below,
473+
for the NUL-terminated 1-byte string [in, in + len). Write the result
474+
to out, which has room for len characters, and return its length. */
475+
static Py_NO_INLINE Py_ssize_t
476+
translate_newlines_ucs1(const char *in, Py_ssize_t len, char *out,
477+
int *seennl)
478+
{
479+
const char *s = in, *end = in + len;
480+
char *o = out;
481+
int seen = *seennl;
482+
for (;;) {
483+
unsigned char c;
484+
const char *p = skip_to_newline_ucs1(s, end, o);
485+
o += p - s;
486+
s = p;
487+
while ((c = (unsigned char)*s++) > '\r') {
488+
*o++ = (char)c;
489+
}
490+
if (c == '\n') {
491+
*o++ = '\n';
492+
seen |= SEEN_LF;
493+
continue;
494+
}
495+
if (c == '\r') {
496+
if (*s == '\n') {
497+
s++;
498+
seen |= SEEN_CRLF;
499+
}
500+
else {
501+
seen |= SEEN_CR;
502+
}
503+
*o++ = '\n';
504+
continue;
505+
}
506+
if (s > end) {
507+
break;
508+
}
509+
*o++ = (char)c;
510+
}
511+
*seennl = seen;
512+
return o - out;
513+
}
514+
327515
PyObject *
328516
_PyIncrementalNewlineDecoder_decode(PyObject *myself,
329517
PyObject *input, int final)
@@ -440,6 +628,10 @@ _PyIncrementalNewlineDecoder_decode(PyObject *myself,
440628
/* We have already seen all newline types, no need to scan again */
441629
if (seennl == SEEN_ALL)
442630
goto endscan;
631+
if (kind == PyUnicode_1BYTE_KIND) {
632+
seennl = scan_newlines_ucs1(in_str, len, seennl);
633+
goto endscan;
634+
}
443635
for (;;) {
444636
Py_UCS4 c;
445637
/* Fast loop for non-control characters */
@@ -481,6 +673,11 @@ _PyIncrementalNewlineDecoder_decode(PyObject *myself,
481673
goto error;
482674
}
483675
in = out = 0;
676+
if (kind == PyUnicode_1BYTE_KIND) {
677+
out = translate_newlines_ucs1(in_str, len, translated,
678+
&seennl);
679+
goto translated_all;
680+
}
484681
for (;;) {
485682
Py_UCS4 c;
486683
/* Fast loop for non-control characters */
@@ -505,6 +702,7 @@ _PyIncrementalNewlineDecoder_decode(PyObject *myself,
505702
break;
506703
PyUnicode_WRITE(kind, translated, out++, c);
507704
}
705+
translated_all:
508706
Py_DECREF(output);
509707
output = PyUnicode_FromKindAndData(kind, translated, out);
510708
PyMem_Free(translated);
@@ -2385,12 +2583,21 @@ _textiowrapper_readline(textio *self, Py_ssize_t limit)
23852583
line_len = PyUnicode_GET_LENGTH(line);
23862584
kind = PyUnicode_KIND(line);
23872585

2388-
endpos = _PyIO_find_line_ending(
2389-
self->readtranslate, self->readuniversal, self->readnl,
2390-
kind,
2391-
ptr + kind * start,
2392-
ptr + kind * line_len,
2393-
&consumed);
2586+
if (kind == PyUnicode_1BYTE_KIND
2587+
&& self->readuniversal && !self->readtranslate)
2588+
{
2589+
/* newline='' */
2590+
endpos = find_line_ending_ucs1(ptr + start, ptr + line_len,
2591+
&consumed);
2592+
}
2593+
else {
2594+
endpos = _PyIO_find_line_ending(
2595+
self->readtranslate, self->readuniversal, self->readnl,
2596+
kind,
2597+
ptr + kind * start,
2598+
ptr + kind * line_len,
2599+
&consumed);
2600+
}
23942601
if (endpos >= 0) {
23952602
endpos += start;
23962603
if (limit >= 0 && (endpos - start) + chunked >= limit)

0 commit comments

Comments
 (0)