Repository navigation
gh-161145: Test more bytes simultaneously in find_first_nonascii - #161147
Conversation
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>
|
See #122902 which is similar. |
|
Undrafting, #122902 has landed. I reran the perf numbers and this still improves performance of these cases. |
|
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 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 // 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:
This table is full of numbers, it's hard to read :-( Let's compare 16words to 8words instead:
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: Detailsimport 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}) |
|
I checked the x86-64 assembly code of find_first_nonascii(). On the main branch, it uses one 64-bit DetailsWith this change, it uses 4 x 128-bit reads (ex: Details |
vstinner
left a comment
There was a problem hiding this comment.
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.
Co-authored-by: Victor Stinner <vstinner@python.org>
|
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. |
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 😁 |
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: