| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Python's regex prefix scanning loops advance one byte at a time to find the first character of a literal prefix. For SIZEOF_SRE_CHAR == 1, memchr is a drop-in replacement that uses SIMD. The idea is to skip say 32 bytes per iteration in the negative case (no match).
The two loops are at
cpython/Modules/_sre/sre_lib.h
Lines 1756 to 1759 in 7ce737e
| while (*ptr != c) { | |
| if (++ptr >= end) | |
| return 0; | |
| } |
cpython/Modules/_sre/sre_lib.h
Lines 1789 to 1792 in 7ce737e
| while (*ptr++ != c) { | |
| if (ptr >= end) | |
| return 0; | |
| } |
Similar issues/prs are gh-145797 (memchr in str.split) and building on gh-57343, gh-57345.
Proposed patch:
--- a/Modules/_sre/sre_lib.h
+++ b/Modules/_sre/sre_lib.h
@@ -1753,10 +1753,19 @@
end = (SRE_CHAR *)state->end;
state->must_advance = 0;
while (ptr < end) {
+#if SIZEOF_SRE_CHAR == 1
+ {
+ SRE_CHAR *found = memchr(ptr, c, end - ptr);
+ if (!found)
+ return 0;
+ ptr = found;
+ }
+#else
while (*ptr != c) {
if (++ptr >= end)
return 0;
}
+#endif
TRACE(("|%p|%p|SEARCH LITERAL\n", pattern, ptr));
state->start = ptr;
@@ -1786,10 +1795,19 @@
while (ptr < end) {
SRE_CHAR c = (SRE_CHAR) prefix[0];
+#if SIZEOF_SRE_CHAR == 1
+ {
+ SRE_CHAR *found = memchr(ptr, c, end - ptr);
+ if (!found)
+ return 0;
+ ptr = found + 1;
+ }
+#else
while (*ptr++ != c) {
if (ptr >= end)
return 0;
}
+#endif
if (ptr >= end)
return 0;I ran into this while profiling a log parser (pretty much grepping for error and warning strings) that applies a handful of literal-prefix regexes to each line of a ~5MB log file. With the patch, the runtime is reduced by ~14% end-to-end. A similar use case in the same application is scanning a binary executable/library for strings with a pattern /common/prefix/(foo|bar|baz) where I would benefit even more from memchr to find the / anchors.
Microbenchmark on my M4 macbook (without PGO):
Single-char prefix re.compile(r"X(A|B)"), match at position N against aaa...aXA
| match position | baseline | patched | speedup |
|---|---|---|---|
| 0 | 55 ns | 58 ns | ~flat |
| 50 | 70 ns | 53 ns | 1.3x |
| 100 | 83 ns | 55 ns | 1.5x |
| 500 | 217 ns | 62 ns | 3.5x |
| 1,000 | 326 ns | 70 ns | 4.7x |
| 10,000 | 2,379 ns | 219 ns | 10.9x |
| 100,000 | 23,348 ns | 1,576 ns | 14.8x |
Multi-char prefix re.compile(r"XXXX(A|B)"), match at position N against aaaa...aXXXXA
| match position | baseline | patched | speedup |
|---|---|---|---|
| 0 | 57 ns | 56 ns | ~flat |
| 100 | 71 ns | 58 ns | 1.2x |
| 500 | 142 ns | 65 ns | 2.2x |
| 1,000 | 221 ns | 72 ns | 3.1x |
| 10,000 | 1,652 ns | 225 ns | 7.3x |
| 100,000 | 15,952 ns | 1,621 ns | 9.8x |
The reason the second benchmark is not as good as the first is that the apple clang compiler seems to have unrolled the second loop 4x while the first loop is not unrolled at all. Neither of them were auto-vectorized.
Synthetic benchmark"""Synthetic benchmark for SRE prefix scanning at various string lengths."""
import re
import time
def bench(pat, text, iterations):
for _ in range(1000):
pat.search(text)
t0 = time.perf_counter()
for _ in range(iterations):
pat.search(text)
dt = time.perf_counter() - t0
ns_per_call = dt / iterations * 1e9
print(f" {ns_per_call:8.1f} ns/call")
# Single-char prefix: X(A|B) has prefix "X" (len=1)
pat1 = re.compile(r"X(A|B)")
print("Single-char prefix: X(A|B), match at position N")
for n in [0, 5, 10, 50, 100, 500, 1000, 10000, 100000]:
text = "a" * n + "XA"
print(f" match at pos {n:>6d} (len={len(text):>6d})", end="")
bench(pat1, text, max(100000, 1000000 // max(n, 1)))
# Multi-char prefix: XXXX(A|B) has prefix "XXXX" (len=4)
pat4 = re.compile(r"XXXX(A|B)")
print()
print("Multi-char prefix: XXXX(A|B), match at position N")
for n in [0, 5, 10, 50, 100, 500, 1000, 10000, 100000]:
text = "a" * n + "XXXXA"
print(f" match at pos {n:>6d} (len={len(text):>6d})", end="")
bench(pat4, text, max(100000, 1000000 // max(n, 1)))This is a minor feature, which does not need previous discussion elsewhere
No response
| Back | FazBrowse Home | New Git URL |
Activity
haampie commented on Apr 18, 2026
I'll check later if I can get some numbers with PGO enabled, if someone doesn't beat me to it.
Edit: with PGO the timings are pretty much the same.
pythongh-148729: use memchr in SRE prefix scan
picnixz commented on Apr 20, 2026
Out of curiosity, how does GCC fare? please make PGO+LTO benchmarks (not just PGO) as well as they are also redistributed as is. If possible it would be great to know about Windows / Linux builds.
haampie commented on Apr 20, 2026
I won't be able to verify that now, but will check somewhere in the next few days.
For what it's worth, I'm slightly unhappy with the fact that only UCS-1 is fast with the patch and not UCS-2/UCS-4. Add one emoji to a string, and suddenly the regex runs 10x slower. Do you think that should be addressed? Or better to stick to this small, incremental improvement just for latin-1 strings and byte arrays?
I'm struggling a bit with getting uniformly good performance. The prefix matching is exactly str.find, so in principle I could reuse that code, but even then there are a few considerations.
The TL;DR:
- wmemchr can be slow; and its emulation already written in cpython is usually fast, but also not always.
- The call site in sre would still be somewhat complex.
Details:cpython/Objects/stringlib/fastsearch.h
Line 50 in 5a3f479
cpython/Objects/stringlib/fastsearch.h
Line 773 in 5a3f479
cpython/Objects/stringlib/fastsearch.h
Lines 57 to 62 in 5a3f479
cpython/Objects/stringlib/fastsearch.h
Lines 67 to 70 in 5a3f479
haampie commented on Apr 21, 2026
After playing around more I think I would go with find_char from fastsearch.h to jump to the next prefix match. That dispatches to memchr for the UCS-1 case, with reasonable fallback for the UCS-2 and UCS-4 cases.
Using fastsearch.h's algorithm for str.find in the regex engine is probably not a good idea. The regex compiler precomputes an overlap table once and for all, whereas FASTSEARCH has to do similar bookkeeping on every call.
haampie commented on Apr 22, 2026
I now have data for GNU Linux and Darwin with PGO+LTO, for the UCS-1, UCS-2, and UCS-4 cases, using stringlib's find_char at commit haampie@6e1b1b9.
Benchmark 1 is basically grepping for things like def \w in cpython's Python sources, and should be realistic example.
Benchmark 2 is a synthetic micro-benchmark for best and worst case scenarios for the new code.
It shows the pros and cons of memchr well. The regression on darwin is because the letter i has a density of 2.88% in the corpus; the cross-over density is apparently about 2%, below which memchr is faster. However, on the intel CPU the cross-over point is closer to 20% or so, and it's a uniform speedup in practice.
Realistic benchmark
x86_64-linux-gnu, GCC 10, Intel(R) Xeon(R) CPU E5-2620 v3
Machine cfarm13 from https://portal.cfarm.net/machines/list/
arm64-apple-darwin24.6.0, Apple clang 17.0.0, M4
Synthetic micro-benchmark
x86_64-linux-gnu, GCC 10, Intel(R) Xeon(R) CPU E5-2620 v3
arm64-apple-darwin24.6.0, Apple clang 17.0.0, M4
serhiy-storchaka commented on Jul 6, 2026
Sorry for the delay. Yes, find_char is the right direction: the low-byte memchr filter is exactly what the UCS2/UCS4 cases need, and one emoji should not cost 10x.
On including the header: _sre is a core module, so including stringlib/fastsearch.h is technically possible, but it drags the whole two-way search machinery into _sre for one function, and the STRINGLIB_* parametrization would have to be repeated for each of the three SRE_CHAR widths inside an already thrice-included template. I would rather factor find_char out of fastsearch.h into a small separate stringlib/find_char.h included by both — one implementation, no duplication. Copying the ~40-line loop into sre_lib.h under SIZEOF_SRE_CHAR guards is an acceptable fallback.
Two things to keep when you reopen the PR: both scan loops should be converted (the prefix_len == 1 path also serves single-literal patterns, and the overlap-table path needs the "points one past the candidate" invariant preserved), and the s[n] over-read caveat at the top of fastsearch.h does not apply to find_char — worth a comment either way.
FWIW, I independently reproduced the plain-memchr variant for SIZEOF_SRE_CHAR == 1 and measured ~21x on scanning 8-bit haystacks (miss and late-hit alike), with 217k search/finditer span comparisons against the unpatched engine over bytes and UCS1/UCS2/UCS4 haystacks — no differences. The differential harness applies unchanged to the find_char version; happy to share it.
haampie commented on Jul 6, 2026
Happy to try it on the machines I have access to.
maurycy commented on Oct 7, 2026
@haampie Is this still on your list? If not, I can pick it up following @serhiy-storchaka's find_char.h suggestion. Happy to just review or test if you'd rather keep it!