Repository navigation
re: no ASCII fast path for \w in Unicode mode, unlike \s #159251
Description
Feature or enhancement
Use the existing ASCII table to speed up word matching for ASCII characters, while keeping Unicode by default.
For example:
>>> import re
>>> re.findall(r"\w+", "hello café 123")
['hello', 'café', '123']Most characters in this example are ASCII, but the re still uses Unicode classification for each character that \w examines.
Idea
The idea is for chars less than 128, use SRE_IS_WORD(). For the rest, keep the existing Unicode predicate:
#define SRE_UNI_IS_WORD(ch)\
((ch) < 128 ? SRE_IS_WORD(ch) : (SRE_UNI_IS_ALNUM(ch) || (ch) == '_'))The same predicate is used by \W, \b and \B so it'd speed them up, too.
We're doing it for \s:
cpython/Include/cpython/unicodeobject.h
Lines 753 to 758 in 9028df3
| static inline int Py_UNICODE_ISSPACE(Py_UCS4 ch) { | |
| if (ch < 128) { | |
| return _Py_ascii_whitespace[ch]; | |
| } | |
| return _PyUnicode_IsWhitespace(ch); | |
| } |
Microbenchmark
| Benchmark | main | patched |
|---|---|---|
\w+ findall, ASCII prose |
295 us | 235 us (1.25x faster) |
\w+|[^\w\s]+ (NLTK WordPunct), ASCII prose |
417 us | 327 us (1.28x faster) |
\W+ findall, ASCII prose |
321 us | 276 us (1.16x faster) |
\b\w findall, ASCII prose |
347 us | 223 us (1.55x faster) |
\w+ findall, ASCII punctuation only |
132 us | 81.8 us (1.61x faster) |
\w+ findall, Latin-1 prose |
326 us | 270 us (1.21x faster) |
\w+ findall, CJK text |
71.8 us | 77.4 us (1.08x slower) |
\w+ findall, mixed scripts |
206 us | 172 us (1.20x faster) |
\w+ findall with re.ASCII (control) |
229 us | not significant |
\d+ findall, ASCII prose (control) |
154 us | 165 us (1.07x slower) |
Linux (AMD EPYC 4484PX, Ubuntu 24.04, gcc 13), PGO+LTO builds of main at 9028df3
perf stat -r 5 on the same machine:
| Case | main instructions | patched instructions | main cycles | patched cycles |
|---|---|---|---|---|
\w+ ASCII prose |
14.85G | 12.14G (-18%) | 3.20G | 2.61G (-18%) |
| NLTK WordPunct, ASCII prose | 15.64G | 12.84G (-18%) | 3.32G | 2.68G (-19%) |
\w+ CJK text |
13.63G | 13.50G (-1%) | 2.97G | 3.27G (+10%) |
\w+ with re.ASCII (control) |
12.08G | 12.09G (0%) | 2.57G | 2.55G (-1%) |
\d+ ASCII prose (control) |
14.85G | 14.85G (0%) | 3.03G | 3.09G (+2%) |
So the \d+ is likely just noise between PGO+LTO builds, but 100% CJK seems to be real cost. Are we ok with it?
pyperf microbenchmark
import re
import pyperf
ascii_text = ("The quick brown fox jumps over the lazy dog, said Alice; "
"it was 42 degrees outside and the kettle_was on. ") * 200
latin_text = ("Zażółć gęślą jaźń, señor; la niña está aquí: café, "
"naïve, crème brûlée. ") * 300
cjk_text = "漢字仮名交じり文で書かれた文章です。これはテストの文章です。" * 300
mixed_text = (ascii_text[:2000] + latin_text[:1000] + cjk_text[:500]) * 4
punct_text = "!!! ??? ... --- *** ((( ))) ;;; ::: ,,, " * 300
word_run = re.compile(r"\w+")
nltk_wordpunct = re.compile(r"\w+|[^\w\s]+")
nonword_split = re.compile(r"\W+")
boundary = re.compile(r"\b\w")
ascii_flag = re.compile(r"\w+", re.ASCII)
digit_run = re.compile(r"\d+")
def findall(pattern, text):
return pattern.findall(text)
runner = pyperf.Runner()
runner.bench_func(r"\w+ findall, ASCII prose", findall, word_run, ascii_text)
runner.bench_func(r"\w+|[^\w\s]+ (NLTK WordPunct), ASCII prose", findall, nltk_wordpunct, ascii_text)
runner.bench_func(r"\W+ findall, ASCII prose", findall, nonword_split, ascii_text)
runner.bench_func(r"\b\w findall, ASCII prose", findall, boundary, ascii_text)
runner.bench_func(r"\w+ findall, ASCII punctuation only", findall, word_run, punct_text)
runner.bench_func(r"\w+ findall, Latin-1 prose", findall, word_run, latin_text)
runner.bench_func(r"\w+ findall, CJK text", findall, word_run, cjk_text)
runner.bench_func(r"\w+ findall, mixed scripts", findall, word_run, mixed_text)
runner.bench_func(r"\w+ findall with re.ASCII (control)", findall, ascii_flag, ascii_text)
runner.bench_func(r"\d+ findall, ASCII prose (control)", findall, digit_run, ascii_text)Has this already been discussed elsewhere?
No response given
Links to previous discussion of this feature:
No response
Activity
maurycy commentedon Oct 12, 2026
cc @serhiy-storchaka if we're ok with the trade-off (slowdown for no ASCII, like pure CJK)? If so, perhaps we could think about
Py_UNICODE_ISALNUM?terryjreedy commentedon Oct 12, 2026
Preliminary question: would it make sense to expand ascii table(s?) to latin1 table(s?) and/or add -N flag (N for latiN since L is taken)?
Assuming that re has access to the internal CPython string kind (1/2/4 bytes per char), I should think it would.
I also expect the slowdown for any all-non-latin1 string. Though it would make me sad to completely give up the speedup for the ascii/latin1 world, I don't think the tradeoff is fair. Let's think harder about how to reduce the tradeoff here and maybe elsewhere. The ascii-else-unicode expression is only needed for long-enough mixed ascii/non-ascii strings with enough ascii to be a net benefit. Perhaps it could be made opt-in.
Note: I am not sure what happens when one compiles a mixed string with the .A flag.
picnixz commentedon Oct 13, 2026
For CJK texts: can we have a adaptive threshold that enables the optimization according to the amount of text being parsed instead? usually regexp are for finding things in text of your own language. So if the input is CJK text, obvioulsy, hitting
\wwould be less frequent.That being said, it also makes me wonder whether we can have optimization flags, e.g., thehe user says "this regex will be used for English text" and thus we can enable specific optimizations in general (not just the re.ASCII one). Or disable it if necessayr. I don't know how hard it is tfor that though.
cc @serhiy-storchaka
maurycy commentedon Oct 13, 2026
Thank you for the comments.
I reran
perf stat -r 5more rigorously (only CJK; two builds per branch, two interleaved runs):Also,
perf recordshows more samples insre_category(a1: 972, b1: 1136, b2: 1129), andcmp $0x7f,%esiis the hottest instr there. The profile also changes elsewhere, particularly insre_ucs2_count. (I can share but rechecking would be valuable, too.) Specifically, PGO+LTO places thech >= 128path about 2KB away from the compare, with a jump back.Hypothesis: PGO training is mostly ASCII, so CJK is a regression but its size depends on where it lands in the op cache. That'd explain why b1 is 3% and b2 is 11%.
@terryjreedy Thinking harder how to reduce the trade-off:
(already updated in #159252 as 6c5f44f)
As above, two builds per variant:
\w+ASCII prose\w+|[^\w\s]+(NLTK WordPunct), ASCII prose\W+ASCII prose\b\wASCII prose\w+ASCII punctuation only\w+Latin-1 prose\w+CJK text\w+mixed scripts\w+withre.ASCII(control)\d+ASCII prose (control)perf stat -r 5(only CJK):str.isalnum()microbenchmark:str.isalnum(), ASCII word 8000 charsstr.isalnum(), ASCII prose (fails at char 4)str.isalnum(), ASCII word + trailing '!'str.isalnum(), Latin-1 word 7500 charsstr.isalnum(), CJK word 8500 charsstr.isalnum(), CJK prose (fails at char 17)str.isalnum(), 1200 short wordsstr.isalnum(), 3600 prose tokensstr.isalnum() microbenchmark
I note
cpython/Lib/test/test_capi/test_unicode.py
Lines 2600 to 2614 in 48ddda1
http://github.com/python/cpython/issues/159251#issuecomment-6140434277
maurycy commentedon Oct 13, 2026
I think that
(ALPHA_MASK | DECIMAL_MASK | DIGIT_MASK | NUMERIC_MASK)) != 0might be the answer here.It could be also in
str.islower()((flags & (UPPER_MASK | TITLE_MASK)) != 0),str.isupper()((flags & (LOWER_MASK | TITLE_MASK)) != 0),str.istitle()((flags & (TITLE_MASK | UPPER_MASK)) != 0). Then, we could go withLuas(flags & (UPPER_MASK | ALPHA_MASK)) == (UPPER_MASK | ALPHA_MASK),Cfas(flags & (CASE_IGNORABLE_MASK | PRINTABLE_MASK)) == CASE_IGNORABLE_MASKetc.What do you think?
I can extend the PR to all masks and add missing test coverage, but would prefer to make sure we're on the same boat. This is a bit more than the initial
ch < 128.