Skip to content

_sre: drive a non-ASCII str subject through a character index on the string - #8522

Merged
youknowone merged 2 commits into
RustPython:mainfrom
youknowone:sre-utf8-index
Aug 14, 2026
Merged

_sre: drive a non-ASCII str subject through a character index on the string#8522
youknowone merged 2 commits into
RustPython:mainfrom
youknowone:sre-utf8-index

Conversation

@youknowone

@youknowone youknowone commented Aug 14, 2026

Copy link
Copy Markdown
Member

Follow-up to #8520, which made re linear on all-ASCII str subjects by driving them
over their bytes. A subject with one non-ASCII character still took the &Wtf8 drive and
was still quadratic: at n=20000, finditer took 1.27 s and a backreference scan 7.8 s.
They now take 4.1 ms and 3.4 ms.

Why it was quadratic

StrDrive positions are character indices, and the &Wtf8 drive resolves one by decoding
from the start of the subject. Four separate paths pay that:

where per
count() Request::new clamps end with it scanner step
create_cursor a scanner step starts from a default State, whose cursor is null scanner step
create_cursor GROUPREF derives the group's cursor backtrack attempt
slice() code_points().take(end).skip(start) extracted group

The first two make finditer quadratic; the last makes findall/sub/split/group
quadratic even though their SearchIter already advances one cursor relatively.

Making the engine walk relatively instead would fix the cursor cases but not slice: a
Match outlives the scan that produced it and has no cursor to be relative to. What all
four have in common is re-deriving something about the subject that the string could
answer once, so that is where the answer goes.

The change

Wtf8Index (new, in rustpython-common) is a table over a WTF-8 buffer: one 24-byte group
per 64 code points, holding a byte offset every fourth code point, so a lookup is one table
read and at most two steps. The layout is PyPy's UTF8_INDEX_STORAGE
(rpython/rlib/rutf8.py), which solves the same problem for the same reason.

StrData builds one on the first call to the new char_index_to_byte, in a slot published
by compare-exchange next to the existing character-length cache, and drops it with the
string. ASCII strings answer from the index itself and never build a table.

_sre's Utf8Str drive then holds the PyStr rather than a &Wtf8: count is the cached
character length, and create_cursor and slice resolve through the table. Stepping is the
&Wtf8 drive's, unchanged -- the subject is the same buffer decoded the same way, so only
the operations that resolve a position from scratch differ.

Measurements

Best of 3, same machine, paired against the parent commit. Each row doubles n, so x4 is
quadratic and x2 is linear.

n=20000 before after
finditer 1266.26 ms (x3.70) 4.05 ms (x1.87) 313x
group(0) 2430.02 ms (x3.91) 6.07 ms (x2.05) 400x
findall 1638.42 ms (x4.87) 2.28 ms (x1.98) 719x
sub 1308.10 ms (x4.27) 2.51 ms (x2.04) 521x
split 1221.60 ms (x3.98) 2.49 ms (x2.07) 491x
backreference findall 7759.22 ms (x4.75) 3.37 ms (x2.04) 2302x
finditer, lone-surrogate subject 1187.82 ms (x3.99) 4.53 ms (x1.98) 262x

The subject is ("항목%04d " % 7) * n and the pattern \d+; the surrogate row replaces the
first character with \ud800 so the subject is WTF-8 rather than UTF-8.

Cost

sys.getsizeof('') goes from 88 to 96: one word per string, whether or not it is ever
indexed. A string that is indexed pays a further 0.375 bytes per character, and one O(n)
build, on the first lookup.

The paths that must not move, measured by running both binaries alternately (best of 7,
three rounds, minimum reported):

n=20000 before after
ascii finditer 4.66 ms 4.77 ms
ascii findall 2.67 ms 2.68 ms
ascii sub 2.81 ms 2.90 ms
bytes findall 2.41 ms 2.47 ms
[w + "!" for w in words] 1.37 ms 1.35 ms
" ".join(words).split() 0.86 ms 0.86 ms
s[i] in a loop 0.25 ms 0.26 ms

Within 3%, in both directions, and the pure-string rows do not move. The bytes row shifts
by the same 2.5% as the ASCII ones while touching no PyStr at all, so what is left is
binary layout rather than the extra word -- I would not claim these numbers separate a
sub-3% effect.

Correctness

  • test_re: run=166, skipped=14, SUCCESS.
  • test_re test_str test_string test_bytes test_json test_codecs test_ucn: 1198 run, 68
    skipped, byte-identical results before and after.
  • The 2731-line CPython 3.14.6 differential from _sre: drive an all-ASCII str subject over its bytes #8520 -- 26 patterns × 15 subjects × 7
    operations, straddling the ASCII boundary, including a lone surrogate and an embedded NUL
    -- still produces 0 differences.
  • Wtf8Index has unit tests checking every index against the buffer's own iterator, over
    each encoded width, the group and entry boundaries (1, 3, 4, 5, 63, 64, 65, 127, 128, 129,
    255, 256, 257 code points), mixed widths, and lone surrogates.

SreStr for &Wtf8 has no callers left and is removed; the StrDrive impl stays, since
Utf8Str steps through it.

Not in this PR

StrData::nth_char -- and so str.__getitem__ -- is still O(n) on a non-ASCII string, and
the same table would answer it in constant time. It is left alone because the tradeoff is
different there: a single subscript would pay an O(n) build to replace an O(n) walk, so it
needs a policy for when building is worth it, rather than the unconditional build a regex
scan justifies.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Improved character-position lookups for strings, including non-ASCII text.
    • Added efficient string slicing and matching support based on character indices.
    • Out-of-range character positions now resolve safely to the string’s end.
  • Performance

    • Accelerated repeated character indexing and regular-expression operations on non-ASCII strings.

`Wtf8`'s iterators are sequential, so resolving a code point index through
them is O(n) and code that indexes the same string repeatedly walks it once
per index. `Wtf8Index` is a side table -- one 24-byte group per 64 code
points, 0.375 bytes per code point -- that answers the same question in
constant time; the layout is PyPy's `UTF8_INDEX_STORAGE`.

`StrData` builds one on the first call to the new `char_index_to_byte`, in a
slot published by compare-exchange, and drops it with the string. ASCII
strings answer from the index itself and never build a table. A clone gets an
empty slot, since it indexes its own copy of the buffer.

Assisted-by: Claude
The `&Wtf8` drive answers `count` and `create_cursor` by decoding from the
start of the subject, so a scan that restarts at successive positions walks
the subject once per position, and `slice` walks it again per extracted
group. `Utf8Str` holds the `PyStr` and asks it instead: `count` is the cached
character length, and `create_cursor` and `slice` resolve their positions
through `char_index_to_byte`. Stepping is the `&Wtf8` drive's, unchanged.

The table lives on the string, so a `Match` that outlives the scan shares it
-- `group` has no cursor of its own to move relative to.

`SreStr for &Wtf8` has no callers left; the `StrDrive` impl stays, since
`Utf8Str` steps through it. The three subject helpers now share one downcast.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: f041ddd4-8508-454e-b1eb-160040305838

📥 Commits

Reviewing files that changed from the base of the PR and between d04318e and 30b2aaa.

📒 Files selected for processing (5)
  • crates/common/src/lib.rs
  • crates/common/src/str.rs
  • crates/common/src/wtf8_index.rs
  • crates/vm/src/builtins/str.rs
  • crates/vm/src/stdlib/_sre.rs

📝 Walkthrough

Walkthrough

Added a compact, lazily cached WTF-8 index for character-to-byte lookup. Exposed the lookup through StrData and PyStr. Updated non-ASCII _sre subjects to use byte offsets through the cached index.

Changes

WTF-8 character indexing

Layer / File(s) Summary
WTF-8 index implementation
crates/common/src/lib.rs, crates/common/src/wtf8_index.rs
The public Wtf8Index module and type now build grouped byte-offset tables, provide lookup and storage-size APIs, and include coverage for encoded widths, group boundaries, lone surrogates, and empty input.
StrData index integration
crates/common/src/str.rs, crates/vm/src/builtins/str.rs
StrData now owns a lazy index slot across all constructors. StrData::char_index_to_byte and PyStr::char_index_to_byte resolve character positions to byte offsets.
Non-ASCII SRE subject drive
crates/vm/src/stdlib/_sre.rs
Non-ASCII subjects now use Utf8Str, which converts character positions to byte offsets, rebuilds cursors when positions change, delegates stepping to the WTF-8 drive, and slices by byte offsets. String validation is shared across dispatch paths.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 30b2a

The PR adds indexed character-position handling for non-ASCII strings while preserving existing behavior and performance for unaffected paths. No actionable merge-blocking risk remains beyond normal checks and review.

Suggested reviewers: shaharnaveh

Sequence Diagram(s)

sequenceDiagram
  participant Pattern
  participant Utf8Str
  participant PyStr
  participant StrData
  participant Wtf8Index
  Pattern->>Utf8Str: Construct non-ASCII subject drive
  Utf8Str->>PyStr: Request character index conversion
  PyStr->>StrData: Delegate char_index_to_byte
  StrData->>Wtf8Index: Build or query cached index
  Wtf8Index-->>StrData: Return byte offset
  StrData-->>PyStr: Return byte offset
  PyStr-->>Utf8Str: Return byte offset
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: routing non-ASCII str subjects through a character index in _sre.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@youknowone
youknowone merged commit d609108 into RustPython:main Aug 14, 2026
28 checks passed
@youknowone
youknowone deleted the sre-utf8-index branch August 14, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant