str: take search bounds through the character index - #8530
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughThe change adds reverse byte-to-character indexing for string data. ChangesUnicode string indexing
Estimated code review effort: 3 (Moderate) | ~25 minutes Merge Risk: ⚪ Minimal · up to This change optimizes string search bounds and corrects empty-needle counting while preserving the documented behavior across the reported test matrix; no actionable merge-blocking risk remains after normal checks and review. Sequence Diagram(s)sequenceDiagram
participant PyStr
participant StrData
participant Wtf8Index
PyStr->>StrData: Convert character boundary to byte position
StrData->>Wtf8Index: Resolve non-ASCII byte boundary
Wtf8Index-->>StrData: Return character index
StrData-->>PyStr: Return converted index
PyStr->>PyStr: Search the byte range
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
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. Comment |
str.count with an empty needle ran find_iter over the range's bytes, which
reports one position per encoded byte boundary rather than one per
character: '가나다'.count('') answered 10 instead of 4. Count the range's
code points instead; bytes.count keeps the byte-position answer.
Assisted-by: Claude
find, rfind, index, rindex, count, startswith and endswith resolved their
start/stop bounds with AnyStr::get_chars, which walks the payload to both
bounds, and find reported its hit by counting the code points in front of
it -- so a sweep over a subject's own indices walked it once per call.
Resolve the bounds with StrData::char_range_to_bytes instead, which is the
converter the subscript and slice paths already use, and map the hit back
with Wtf8Index::char_index_at_byte, added here as the table's inverse: a
bracketed search over the groups, then at most an entry's worth of steps.
The payload alone does not say whether it is ASCII, so get_chars walked
there too; that path is now off it as well.
n=8000, sweeping the bound over the subject, best of 3, interleaved:
'가나다라'*n/4 'abcd'*n/4
find 18.00ms -> 0.15ms 3.48ms -> 0.14ms
startswith 16.75ms -> 0.12ms
count 22.78ms -> 4.02ms
count stays linear in the range it is given, as it is in CPython.
Assisted-by: Claude
3511d1a to
465a11a
Compare
|
✅ Action performedFull review finished. |
Follows #8522 and #8526, which gave
PyStra code-point index (Wtf8Index) and used it to drivereand to resolve subscripts and slices. The same index answers the remaining quadratic in the string type: the search methods.What was happening
find,rfind,index,rindex,count,startswithandendswithall take character bounds. They resolved them throughAnyStr::get_chars, which walks the payload to both bounds, andfindthen reported its hit with_to_char_idx, which counts the code points in front of it -- the// FIXME: two traversals of str is expensivethat sat above it.So a sweep over a subject's own indices walked the subject once per call.
AnyStrsees only the payload, which does not say whether it is ASCII, so the ASCII path walked too.What this does
Resolve the bounds with
StrData::char_range_to_bytes, the converter #8526 added for slices -- so the search methods inherit its short-walk case too, ands.startswith(x, 1, -1)does not build a table over the whole string. Map the hit back withWtf8Index::char_index_at_byte, added here as the table's inverse. The table is keyed by character index, so the inverse is a search rather than a lookup, but a narrow one: a character is one to four bytes, which brackets the answer to a band around the byte offset before the first comparison, and the walk that follows is at most an entry's worth of steps. It is also amortised -- the callers that need it have already built the table resolving their own bounds.AnyStr::get_charskeeps its byte-string callers, where a character range is already a byte range; theFIXMEis replaced by a note saying so.Measurements
n=8000, sweeping the bound over the subject, best of 3, the two binaries interleaved:
s.find(x, i)'가나다라' * n/4s.startswith(x, i)'가나다라' * n/4s.count(x, i)'가나다라' * n/4s.find(x, i)'abcd' * n/4The per-doubling factor is the clearer statement:
findandstartswithgo from ×4 (quadratic) to ×2 (linear).countstays ×3 because counting is linear in the range it is handed -- CPython is the same shape there; what drops is the constant.The first commit is an independent fix
str.countwith an empty needle answered in encoded positions rather than characters, becausefind_iterover the range's bytes reports one hit per byte boundary:This is on main today, independent of the rest of the PR; it is fixed first, in the shape main has, so it can be read on its own.
bytes.countkeeps the byte-position answer, which is correct there. The snippet assertions added with it fail on main and pass after.Verification
find/rfind/index/rindex/count/startswith/endswithagainst CPython over 356048 shapes -- 16 subjects (empty, ASCII, 2/3/4-byte, lone surrogates, embedded NUL, and lengths on and around the 64-character group boundary) x 11 needles (absent, ASCII, non-ASCII, multi-character, a lone surrogate) x 17 x 17 bounds (None, in range, past the end, negative, far negative). main differs from CPython on 1375 of them, all of them the empty-needlecount; this branch differs on 0.redifferential from _sre: drive a non-ASCII str subject through a character index on the string #8522 (2731 lines) and the subscript/slice differential from str: resolve subscripts and slices through the code point index #8526 (44331 shapes): 0 diffs.test_str,test_bytes,test_re,test_string,test_userstring: SUCCESS. (test_unicodefails identically on main -- it does not reach its tests on either.)cargo clippy --all-targets,cargo fmt --check,cargo test -p rustpython-commonclean.Summary by CodeRabbit
Bug Fixes
Performance
What a single call pays
The table costs a pass over the buffer, so the fair question is what happens to a string that is searched once and never again. Measured on a fresh subject per call, so nothing is amortised, n=1,000,000:
s.find(miss, len//2)s.find(miss)(no bounds)s.count(x, len//2)s[len//2]A bounded search is a wash: building the table costs about what the walk it replaces cost. An unbounded search is 13x, and that is the common form --
s.find(x)is0..len, whichchar_range_to_bytesanswers by walking zero steps at each end, so the call becomes a plain byte search with no conversion at all. On main it walked the whole string throughget_charsbefore searching it.