Skip to content

_sre: drive an all-ASCII str subject over its bytes - #8520

Merged
youknowone merged 2 commits into
RustPython:mainfrom
youknowone:sre-ascii-drive
Aug 14, 2026
Merged

_sre: drive an all-ASCII str subject over its bytes#8520
youknowone merged 2 commits into
RustPython:mainfrom
youknowone:sre-ascii-drive

Conversation

@youknowone

@youknowone youknowone commented Aug 14, 2026

Copy link
Copy Markdown
Member

re.finditer over an all-ASCII str was quadratic in the subject's length.

Why it was quadratic

StrDrive positions are character indices, so the &Wtf8 drive answers count() by counting
code points from the start of the subject, and create_cursor(n) by walking to n. Both are
O(n). A scan that restarts at successive positions pays that setup once per step, so the subject
is walked again for every match.

For an ASCII subject a character index is a byte index, so &[u8]'s cursor arithmetic is
already the right arithmetic: count is the byte length and create_cursor is a pointer offset.
AsciiStr is a newtype that takes &[u8]'s StrDrive and overrides only slice, to hand back
str instead of bytes. with_sre_str! selects it on StrKind::is_ascii(), which PyStr
decided when the string was built, so the choice is a field load and not a scan.

Matching is unaffected by the choice: StrDrive carries no unicode semantics of its own, because
the engine keys every unicode decision on the compiled pattern's opcode rather than on the
subject type.

Measurements

re.compile(r"\d+") over ("item%04d " % 7) * n, best of 3, same machine and base commit for
both columns (x is the factor over the previous row, so 4 is quadratic and 2 is linear):

n finditer before finditer after m.group(0) before m.group(0) after
5000 36.56 ms 1.21 ms 98.07 ms 1.91 ms
10000 147.56 ms (x4.04) 2.25 ms (x1.87) 352.25 ms (x3.59) 3.81 ms (x1.99)
20000 592.97 ms (x4.02) 4.52 ms (x2.01) 1288.47 ms (x3.66) 8.03 ms (x2.11)
40000 2367.13 ms (x3.99) 9.41 ms (x2.08) 4988.72 ms (x3.87) 15.98 ms (x1.99)

The two arms this does not touch, as controls in the same runs:

n=40000 before after
non-ASCII str finditer (&Wtf8 drive) 5048.97 ms 4908.55 ms
bytes finditer (&[u8] drive) 10.75 ms 9.67 ms

Non-ASCII str is unchanged and still quadratic. Removing that needs a character-index to
byte-offset mapping stored on PyStr, which is a separate change.

Correctness

  • test_re: run=166, skipped=14, SUCCESS.
  • A differential over the ASCII/non-ASCII boundary — 26 patterns × 15 subjects × 7 operations
    (findall, finditer spans/groups, sub, split, match, search, fullmatch) — produces
    2731 lines of output that are identical on CPython 3.14.6 and on this branch. The subjects
    include the empty string, pure ASCII, mixed strings where only the first/middle/last character
    is non-ASCII, Korean, emoji, a lone surrogate, and an embedded NUL, so both drives are
    exercised and the split between them is covered from either side.

The first commit

sre_engine: document that StrDrive positions are character indices writes down the unit
StringCursor::position is in and which engine sites depend on it: _count bounds a repeat with
position + max_count, ASSERT compares a position against a lookbehind width, and
search_info recovers a match start as position - (len - 1). A drive that stored byte offsets
there would leave all of them type-correct and silently wrong; AsciiStr is only sound because
ASCII makes the two units coincide.

It also rewrites the GROUPREF comparison loop as while g_ctx.cursor.position < group_end
instead of for _ in group_start..group_end, since g_ctx is already stepping over exactly the
characters being compared. Both spellings iterate the same number of times, and the normalized
machine code for the crate's consumers is byte-identical across the change (1,217,188 lines
compared), so it is a readability change only.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved group-reference matching to handle character positions correctly.
    • Enhanced regular-expression processing for ASCII and Unicode strings.
    • Improved handling of string subjects across different character encodings.
  • Documentation

    • Clarified cursor positioning and character-to-byte mapping behavior for string processing.

StrDrive carried no documentation, so nothing recorded that a cursor's
position is a character index rather than a byte offset. Every implementation
satisfies it -- skip(n) advances position by exactly n in all three -- and the
engine depends on it: _count bounds a repeat with position + max_count and
reports the repeat length as a difference of positions, ASSERT compares
position against a lookbehind width, and search_info recovers a match start as
position - (len - 1). A drive over a variable-width encoding that stored byte
offsets would leave those type-correct and wrong, and would index a lookbehind
out of bounds.

Write the invariant down on StringCursor and on each trait method.

Also walk the group's own cursor in GROUPREF instead of counting to the
group's width, so the loop bound is the thing being stepped. Generated
machine code is unchanged.

Assisted-by: Claude
with_sre_str handed every str subject to the &Wtf8 drive, which answers
count() by counting every code point and create_cursor(n) by stepping over the
first n. Both run once per Request, so a scan that restarts at successive
positions walked the subject again on every call: finditer over an ASCII
subject of n tokens was quadratic in n.

PyStr already records whether it is ASCII -- StrKind is decided when the
string is built -- and for ASCII a character index is a byte index, so the
&[u8] drive's cursor arithmetic already applies: count() is the byte length
and create_cursor() is a pointer offset. Add AsciiStr, which delegates every
StrDrive method to that impl and differs only in slice(), which reslices the
span and returns str rather than bytes.

Matching is unaffected: StrDrive carries no unicode semantics, because the
engine keys every unicode decision on the compiled pattern's opcode rather
than on the subject type.

Also bind the subject once in with_sre_str, so callers passing a temporary
(`&x.clone()`) build it once rather than per arm.

finditer over an ASCII subject, n tokens, this machine:

    n       before      after
     5000    41.60ms     1.37ms
    10000   217.30ms     2.68ms
    20000  1206.28ms     5.17ms
    40000  5016.65ms    10.31ms

Per-doubling x5.22/x5.55/x4.16 becomes x1.95/x1.93/x1.99. Collecting
m.group(0) for every match goes 5061.02ms -> 17.80ms at n=40000.

test.test_re is unchanged at 166 tests, OK (skipped=14, expected failures=6),
and a 2731-line differential over the is_ascii() boundary -- findall,
finditer spans and groups, sub, split, match, search, fullmatch across
subjects that are empty, ASCII, non-ASCII, or mixed -- is byte-identical to
CPython 3.14.6. Introducing an off-by-one in AsciiStr::slice moves 1224 of
those lines, so the comparison reaches the new code.

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: 6b32fe88-0279-40fc-831a-424215659c2f

📥 Commits

Reviewing files that changed from the base of the PR and between d0baa1c and 212d3af.

📒 Files selected for processing (3)
  • crates/sre_engine/src/engine.rs
  • crates/sre_engine/src/string.rs
  • crates/vm/src/stdlib/_sre.rs

📝 Walkthrough

Walkthrough

The SRE engine now treats cursor positions as character indexes. Group matching uses cursor positions as bounds. The VM adds ASCII string adaptation and dispatches byte, ASCII string, and general Unicode string subjects separately.

Changes

ASCII string matching

Layer / File(s) Summary
Cursor contract and group matching
crates/sre_engine/src/string.rs, crates/sre_engine/src/engine.rs
StringCursor::position and StrDrive now document character-based positioning. Group-reference matching uses the referenced cursor position as its loop bound.
ASCII subject adapter and dispatch
crates/vm/src/stdlib/_sre.rs
AsciiStr delegates cursor operations to byte strings and returns bounded str slices. Subject dispatch now distinguishes bytes, ASCII strings, and non-ASCII strings.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: 🟡 Moderate · up to 212d3

The PR changes Rust matching behavior, but required formatting and lint checks have not yet been confirmed. Merge should wait for cargo fmt and cargo clippy to pass, or for an explicit owner acceptance.

Sequence Diagram(s)

sequenceDiagram
  participant Pattern
  participant with_sre_str
  participant AsciiStr
  participant SRECallback
  Pattern->>with_sre_str: Bind the subject
  with_sre_str->>Pattern: Check subject and pattern representation
  Pattern->>AsciiStr: Validate ASCII str and expose bytes
  AsciiStr->>SRECallback: Provide cursor operations and str slices
Loading

Suggested reviewers: hyojongpark

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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: using byte-based driving for all-ASCII str subjects in _sre.
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 d04318e into RustPython:main Aug 14, 2026
28 checks passed
@youknowone
youknowone deleted the sre-ascii-drive branch August 14, 2026 07:07
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