Skip to content

gh-161145: Test more bytes simultaneously in find_first_nonascii - #161147

Merged
cmaloney merged 4 commits into
python:mainfrom
cmaloney:find_first_nonascii_8w
Nov 15, 2026
Merged

cmaloney merged 4 commits into
python:mainfrom
cmaloney:find_first_nonascii_8w

Conversation

@cmaloney

@cmaloney cmaloney commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Long ASCII runs now OR eight aligned words and test the high bits once, one branch per 64 bytes (32 on 32-bit) instead of one per word. When a block contains a non-ASCII byte, the existing one-word loop finds it. It is plain C: GCC and Clang compile it to SSE2/NEON loads, other compilers to ORed word loads with the same single branch.

Multiple compiler and architecture codegen sample: https://godbolt.org/z/8bM83vx3a

pyperf, PGO+LTO, 2 interleaved rounds:

Benchmark main this
(b'a' * 4096).decode() 165 ns 108 ns: 1.53x faster
(b'a' * 200).decode() 28.9 ns 26.5 ns: 1.09x faster
b'a' * 200 + 'é' + ..., .decode() 60.9 ns 56.0 ns: 1.09x faster
b'a' * 136 + 'é' + ..., .decode() 53.8 ns 51.3 ns: 1.05x faster
8-100 byte decodes, 'é' within 100 within 1.2 ns
import pyperf

runner = pyperf.Runner()
tail = 'é'.encode() + b'a' * 50
for n in (8, 100, 200, 4096):
    runner.timeit(f"(b'a' * {n}).decode()", "s.decode()",
                  globals={"s": b'a' * n})
for n in (16, 100, 136, 200):
    runner.timeit(f"(b'a' * {n} + 'é'.encode() + b'a' * 50).decode()",
                  "s.decode()", globals={"s": b'a' * n + tail})

Long ASCII runs now OR eight aligned words and test the high bits once,
one branch per 64 bytes (32 on 32-bit) instead of one per word.  When
a block contains a non-ASCII byte, the existing one-word loop finds it.
It is plain C: GCC and Clang compile it to SSE2/NEON loads, other
compilers to ORed word loads with the same single branch.

Multiple compiler and architecture codegen sample: https://godbolt.org/z/8bM83vx3a

pyperf, PGO+LTO, 2 interleaved rounds:

| Benchmark | main | this |
|---|---:|---:|
| (b'a' * 4096).decode() | 165 ns | 108 ns: 1.53x faster |
| (b'a' * 200).decode() | 28.9 ns | 26.5 ns: 1.09x faster |
| b'a' * 200 + 'é' + ..., .decode() | 60.9 ns | 56.0 ns: 1.09x faster |
| b'a' * 136 + 'é' + ..., .decode() | 53.8 ns | 51.3 ns: 1.05x faster |
| 8-100 byte decodes, 'é' within 100 | | within 1.2 ns |

```python
import pyperf

runner = pyperf.Runner()
tail = 'é'.encode() + b'a' * 50
for n in (8, 100, 200, 4096):
    runner.timeit(f"(b'a' * {n}).decode()", "s.decode()",
                  globals={"s": b'a' * n})
for n in (16, 100, 136, 200):
    runner.timeit(f"(b'a' * {n} + 'é'.encode() + b'a' * 50).decode()",
                  "s.decode()", globals={"s": b'a' * n + tail})
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cmaloney cmaloney removed the skip news label Nov 2, 2026
@cmaloney cmaloney changed the title gh-161145: Test more bytes simultaneously find_first_nonascii gh-161145: Test more bytes simultaneously in find_first_nonascii Nov 2, 2026
@vstinner

vstinner commented Nov 7, 2026

Copy link
Copy Markdown
Member

See #122902 which is similar.

@cmaloney
cmaloney marked this pull request as draft November 8, 2026 19:55
@cmaloney
cmaloney marked this pull request as ready for review November 14, 2026 21:56
@cmaloney

cmaloney commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Undrafting, #122902 has landed. I reran the perf numbers and this still improves performance of these cases.

@vstinner

Copy link
Copy Markdown
Member

tl;dr I tested reading 16 words per branch instead of 8 words per branch: in short, it's not way faster, it is slower overall.


find_max_char() reads 16 words (128 bytes on 64-bit systems) at once and then computes the maximum characters from the or accumulator to build the result.

Your PR reads 8 reads (64 bytes on 64-bit) at once, but it falls back on the loop reading 1 word (8 bytes on 64-bit) at once to detect which byte is non-ASCII.

I was curious to know if it would be more efficient to also process 16 words at once in find_first_nonascii(). I replaced the main loop while (end - p >= 8 * SIZEOF_SIZE_T) { with code adapted from find_max_char():

        // Long ASCII runs: test 16 words (128 bytes on 64-bit) per branch.
        for (; end - p >= 16 * SIZEOF_SIZE_T; p += 16 * SIZEOF_SIZE_T) {
            /* Four independent accumulators so the loop does not serialize on
               one register when the compiler does not vectorize it. */
            size_t v0 = 0, v1 = 0, v2 = 0, v3 = 0;
            for (int i = 0; i < 16 * SIZEOF_SIZE_T; i += 4 * SIZEOF_SIZE_T) {
                const size_t *w = (const size_t *)(p + i);
                v0 |= w[0];
                v1 |= w[1];
                v2 |= w[2];
                v3 |= w[3];
            }
            size_t block = v0 | v1 | v2 | v3;
            if (block & ASCII_CHAR_MASK) {
                break;
            }
        }

In short, (on my laptop), reading 16 words at once is not way faster than reading 8 words: overall, it's 1.01x slower.

Benchmark results:

Benchmark ref 8words 16words
(b'a' * 8).decode() 42.3 ns 43.2 ns: 1.02x slower 43.4 ns: 1.03x slower
(b'a' * 100).decode() 49.0 ns not significant 50.7 ns: 1.03x slower
(b'a' * 200).decode() 58.8 ns 49.3 ns: 1.19x faster 51.5 ns: 1.14x faster
(b'a' * 4096).decode() 241 ns 178 ns: 1.35x faster 162 ns: 1.48x faster
(b'a' * 1 MiB).decode() 68.3 us 55.0 us: 1.24x faster 53.4 us: 1.28x faster
(b'a' * 16 MiB).decode() 2.37 ms 2.06 ms: 1.15x faster 2.03 ms: 1.17x faster
(b'a' * 16 + 'é'.encode() + b'a' * 10).decode() 80.2 ns 75.0 ns: 1.07x faster 82.0 ns: 1.02x slower
(b'a' * 100 + 'é'.encode() + b'a' * 10).decode() 78.2 ns 84.1 ns: 1.08x slower not significant
(b'a' * 136 + 'é'.encode() + b'a' * 10).decode() 90.2 ns 94.9 ns: 1.05x slower 86.6 ns: 1.04x faster
(b'a' * 200 + 'é'.encode() + b'a' * 10).decode() 92.6 ns 87.5 ns: 1.06x faster not significant
(b'a' * 500 + 'é'.encode() + b'a' * 10).decode() 127 ns 107 ns: 1.19x faster 120 ns: 1.06x faster
(b'a' * 1 MiB + 'é'.encode() + b'a' * 10).decode() 132 us 105 us: 1.26x faster 119 us: 1.11x faster
(b'a' * 16 MiB + 'é'.encode() + b'a' * 10).decode() 3.50 ms 3.38 ms: 1.04x faster 3.25 ms: 1.08x faster
(b'a' * 16 + 'é'.encode() + b'a' * 128).decode() 116 ns 112 ns: 1.03x faster 112 ns: 1.03x faster
(b'a' * 100 + 'é'.encode() + b'a' * 128).decode() 123 ns 112 ns: 1.10x faster 116 ns: 1.06x faster
(b'a' * 136 + 'é'.encode() + b'a' * 128).decode() 117 ns 112 ns: 1.04x faster 114 ns: 1.03x faster
(b'a' * 200 + 'é'.encode() + b'a' * 128).decode() 125 ns 117 ns: 1.07x faster 119 ns: 1.05x faster
(b'a' * 500 + 'é'.encode() + b'a' * 128).decode() 187 ns 163 ns: 1.15x faster 163 ns: 1.15x faster
(b'a' * 1 MiB + 'é'.encode() + b'a' * 128).decode() 126 us not significant 120 us: 1.05x faster
(b'a' * 16 MiB + 'é'.encode() + b'a' * 128).decode() 3.55 ms 3.26 ms: 1.09x faster 3.21 ms: 1.11x faster
Geometric mean (ref) 1.09x faster 1.08x faster

This table is full of numbers, it's hard to read :-( Let's compare 16words to 8words instead:

Benchmark 8words 16words
(b'a' * 100).decode() 49.1 ns 50.7 ns: 1.03x slower
(b'a' * 200).decode() 49.3 ns 51.5 ns: 1.05x slower
(b'a' * 4096).decode() 178 ns 162 ns: 1.10x faster
(b'a' * 1 MiB).decode() 55.0 us 53.4 us: 1.03x faster
(b'a' * 16 MiB).decode() 2.06 ms 2.03 ms: 1.02x faster
(b'a' * 16 + 'é'.encode() + b'a' * 10).decode() 75.0 ns 82.0 ns: 1.09x slower
(b'a' * 100 + 'é'.encode() + b'a' * 10).decode() 84.1 ns 78.3 ns: 1.07x faster
(b'a' * 136 + 'é'.encode() + b'a' * 10).decode() 94.9 ns 86.6 ns: 1.10x faster
(b'a' * 200 + 'é'.encode() + b'a' * 10).decode() 87.5 ns 91.7 ns: 1.05x slower
(b'a' * 500 + 'é'.encode() + b'a' * 10).decode() 107 ns 120 ns: 1.12x slower
(b'a' * 1 MiB + 'é'.encode() + b'a' * 10).decode() 105 us 119 us: 1.13x slower
(b'a' * 16 MiB + 'é'.encode() + b'a' * 10).decode() 3.38 ms 3.25 ms: 1.04x faster
(b'a' * 100 + 'é'.encode() + b'a' * 128).decode() 112 ns 116 ns: 1.04x slower
(b'a' * 136 + 'é'.encode() + b'a' * 128).decode() 112 ns 114 ns: 1.01x slower
(b'a' * 200 + 'é'.encode() + b'a' * 128).decode() 117 ns 119 ns: 1.02x slower
(b'a' * 1 MiB + 'é'.encode() + b'a' * 128).decode() 123 us 120 us: 1.02x faster
(b'a' * 16 MiB + 'é'.encode() + b'a' * 128).decode() 3.26 ms 3.21 ms: 1.02x faster
Geometric mean (ref) 1.01x slower

Benchmark hidden because not significant (3): (b'a' * 8).decode(), (b'a' * 16 + 'é'.encode() + b'a' * 128).decode(), (b'a' * 500 + 'é'.encode() + b'a' * 128).decode()

Updated benchmark script:

Details
import pyperf

runner = pyperf.Runner()

for n in (8, 100, 200, 4096):
    runner.timeit(f"(b'a' * {n}).decode()", "s.decode()",
                  globals={"s": b'a' * n})
runner.timeit(f"(b'a' * 1 MiB).decode()", "s.decode()",
              globals={"s": b'a' * (1024 * 1024)})
runner.timeit(f"(b'a' * 16 MiB).decode()", "s.decode()",
              globals={"s": b'a' * (16 * 1024 * 1024)})

for tail in (10, 128):
    for n in (16, 100, 136, 200, 500):
        s = b'a' * n + 'é'.encode() + b'a' * tail
        runner.timeit(f"(b'a' * {n} + 'é'.encode() + b'a' * {tail}).decode()",
                      "s.decode()", globals={"s": s})
    s = b'a' * (1024 * 1024) + 'é'.encode() + b'a' * tail
    runner.timeit(f"(b'a' * 1 MiB + 'é'.encode() + b'a' * {tail}).decode()",
                  "s.decode()", globals={"s": s})
    s = b'a' * (16 * 1024 * 1024) + 'é'.encode() + b'a' * tail
    runner.timeit(f"(b'a' * 16 MiB + 'é'.encode() + b'a' * {tail}).decode()",
                  "s.decode()", globals={"s": s})

@vstinner

Copy link
Copy Markdown
Member

I checked the x86-64 assembly code of find_first_nonascii().

On the main branch, it uses one 64-bit and instruction which reads 64-bit from memory (and r8,QWORD PTR [rax]) per branch:

Details
   0x0000000000788120 <+80>:	add    rax,0x8
   0x0000000000788124 <+84>:	cmp    r9,rax
   0x0000000000788127 <+87>:	jb     0x788220 <find_first_nonascii+336>
   0x000000000078812d <+93>:	mov    r8,rdx
   0x0000000000788130 <+96>:	and    r8,QWORD PTR [rax]
   0x0000000000788133 <+99>:	je     0x788120 <find_first_nonascii+80>

With this change, it uses 4 x 128-bit reads (ex: movdqu xmm0,XMMWORD PTR [rax+0x10]) + 4 x 128-bit por:

Details
   0x00000000007880e0 <+16>:	movabs r8,0x8080808080808080
   ...

   0x0000000000788110 <+64>:	add    rax,0x40
   0x0000000000788114 <+68>:	mov    rdx,rsi
   0x0000000000788117 <+71>:	sub    rdx,rax
   0x000000000078811a <+74>:	cmp    rdx,0x3f
   0x000000000078811e <+78>:	jle    0x788156 <find_first_nonascii+134>

   0x0000000000788120 <+80>:	movdqu xmm0,XMMWORD PTR [rax+0x10]
   0x0000000000788125 <+85>:	movdqu xmm2,XMMWORD PTR [rax+0x20]
   0x000000000078812a <+90>:	movdqu xmm1,XMMWORD PTR [rax+0x30]
   0x000000000078812f <+95>:	movdqu xmm3,XMMWORD PTR [rax]
   0x0000000000788133 <+99>:	por    xmm0,xmm2
   0x0000000000788137 <+103>:	por    xmm1,xmm3
   0x000000000078813b <+107>:	por    xmm0,xmm1
   0x000000000078813f <+111>:	movdqa xmm1,xmm0
   0x0000000000788143 <+115>:	psrldq xmm1,0x8
   0x0000000000788148 <+120>:	por    xmm0,xmm1
   0x000000000078814c <+124>:	movq   rdx,xmm0
   0x0000000000788151 <+129>:	test   rdx,r8
   0x0000000000788154 <+132>:	je     0x788110 <find_first_nonascii+64>

@vstinner vstinner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. While the new code is slower in some cases (search for "slower" in the 8words column of my benchmark below), IMO it's still worth it since ASCII-only is the common case (likely), and "one non-ASCII in the middle of a long ASCII string" is the unlikely case. Also, the slowdown in the unlikely case is acceptable.

Comment thread Objects/unicodeobject.c Resolved
Co-authored-by: Victor Stinner <vstinner@python.org>
@cmaloney

Copy link
Copy Markdown
Contributor Author

Updated to the different loop form. Agreed the slower is less common. For #161146 in particular it goes from "always exactly one byte" to multiple bytes at once most the time, even the slowdown cases will be faster for the tokenizer.

I did look at more generalized SIMD libraries a bit, there are cool tricks to use to get even faster but need to go to platform specific intrinsics which don't seem worth it; this is fast enough and stays fairly legible. Non-obvious why 8 is the right width, but legible what the code is doing.

@cmaloney
cmaloney merged commit 60eca2d into python:main Nov 15, 2026
57 checks passed
@cmaloney
cmaloney deleted the find_first_nonascii_8w branch November 15, 2026 00:32
@vstinner

Copy link
Copy Markdown
Member

I did look at more generalized SIMD libraries a bit, there are cool tricks to use to get even faster but need to go to platform specific intrinsics which don't seem worth it

I agree. I prefer "regular C code" designed in a way so that C compilers can vectorize it, what you did, rather than specialized SIMD code which be less portable and more annoying to maintain.

In CPython, we try to keep the code easy to maintain, and so avoid too crazy optimizations hacks 😁

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants