Skip to content

str, bytes: answer equality with equality rather than with an ordering - #8531

Merged
youknowone merged 1 commit into
RustPython:mainfrom
youknowone:str-equality-not-ordering
Aug 15, 2026
Merged

str, bytes: answer equality with equality rather than with an ordering#8531
youknowone merged 1 commit into
RustPython:mainfrom
youknowone:str-equality-not-ordering

Conversation

@youknowone

@youknowone youknowone commented Aug 14, 2026

Copy link
Copy Markdown
Member

Found while checking whether anything else in str still scales worse than CPython. These are not asymptotic -- they are two O(1) answers that were being computed in O(n).

What was happening

Three places answered == and != by taking Ord::cmp of the two buffers and asking whether the result was Equal:

  • impl Comparable for PyStr (builtins/str.rs)
  • PyBytesInner::cmp (bytes_inner.rs), which serves bytes and bytearray
  • Instruction::CompareOpStr (frame.rs), the specialization the interpreter installs after it sees two exact str operands

An ordering has to read the bytes. [u8]: Ord memcmps the common prefix and only then compares the lengths, so "a" * 1_000_000 == "a" * 999_999 -- an answer the lengths give away -- memcmped a megabyte. And CompareOpStr bypasses the Comparable slot, so it had also lost the identity shortcut the slot takes with identical_optimization: a string compared with itself was read end to end.

CPython answers both with _PyUnicode_Equal, which starts if (str1 == str2) return 1; and then compares kinds and lengths before any content; unicode_richcompare and bytes_richcompare special-case Py_EQ/Py_NE the same way.

What this does

Adds PyComparisonOp::eval_eq, which settles Eq and Ne from an equality test and returns None for an ordering operator (without evaluating the test), and answers through it in the three places. Slice equality checks the length first, and CompareOpStr answers an object compared with itself the way the slot it specializes does.

Measurements

n=1,000,000, per comparison, best of 7:

before after
s == s (the very same object) 23.21 µs 0.16 µs 145x
s == a string one character shorter 24.63 µs 0.17 µs 145x
b == bytes one byte shorter 25.28 µs 0.20 µs 126x
ba == a bytearray one byte shorter 27.68 µs 0.23 µs 120x
s == an equal, distinct string 23.78 µs 24.50 µs unchanged
s < an equal string 29.87 µs 25.07 µs unchanged

CPython answers the first four in 0.02 µs. The remaining 0.16 µs here is the interpreter loop -- an empty lambda call measures 0.12 µs on the same machine.

Verification

Behaviour is unchanged, so the tests added to operator_comparison.py are a guard on the two new shortcuts rather than a regression test -- they pass on main as well. They go through a function and a loop so the operands are not constants the compiler folds and the specialized instruction is actually reached, and they cover: the same object, equal distinct objects, same-length differences, prefixes ("abc" < "abcd"), the empty string, lone surrogates, astral characters, bytes/bytearray in both directions, and comparison against a non-string.

test_str, test_bytes, test_string, test_dict, test_set, test_operator, test_compare, test_richcmp, test_sort, test_userstring, test_collections, test_json: SUCCESS. (test_bytearray and test_unicode fail identically on main -- neither reaches its tests on either.) cargo clippy --all-targets and cargo fmt --check clean.

Summary by CodeRabbit

  • Bug Fixes

    • Improved equality and inequality comparisons for strings, bytes, and bytearrays, including values with different lengths.
    • Preserved correct ordering behavior for less-than and greater-than comparisons.
    • Improved handling of Unicode text, mixed byte types, and comparisons with nonmatching types.
  • Tests

    • Added comprehensive coverage for equality, ordering, prefixes, Unicode edge cases, and repeated comparisons.

Are there others?

Every eval_ord call site in the tree (15 outside slot.rs), checked for the same shape -- an ordering used to answer equality where the ordering costs more:

  • set.rs, dict.rs, iter.rs, array.rs:1286 compare lengths; frame.rs's int/float specializations, int.rs, float.rs compare scalars; cert.rs compares a bool, function.rs an Option::is_none. An ordering is O(1) on all of those, so there is nothing to save.
  • array.rs:1255 already routes Eq/Ne through eq_only before it reaches the ordering path -- the same shape as this change, arrived at independently.
  • BigInt: Ord compares sign and limb count before digits, so int equality already short-circuits on magnitude.

The three fixed here are the ones whose operands are buffers, where the ordering reads O(min(len)) bytes to answer a question the lengths settle.

eval_eq sits beside two existing helpers and is not a duplicate of either: map_eq answers only where its predicate holds (its caller handles the other side), and eq_only declares the comparison NotImplemented for an ordering operator -- correct for a type with no ordering, wrong for str, which has one. The doc comment says so.

@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: 5870fde2-78af-4095-b80f-08b3c29bf225

📥 Commits

Reviewing files that changed from the base of the PR and between 2274cef and 13c127a.

📒 Files selected for processing (5)
  • crates/vm/src/builtins/str.rs
  • crates/vm/src/bytes_inner.rs
  • crates/vm/src/frame.rs
  • crates/vm/src/types/slot.rs
  • extra_tests/snippets/operator_comparison.py

📝 Walkthrough

Walkthrough

String and bytes comparisons now evaluate equality directly. Ordering comparisons retain their existing paths. The change adds PyComparisonOp::eval_eq and expands comparison tests for strings, bytes, bytearrays, Unicode, prefixes, and mixed types.

Changes

Comparison evaluation

Layer / File(s) Summary
Equality evaluation contract
crates/vm/src/types/slot.rs
Adds PyComparisonOp::eval_eq for Eq and Ne; ordering operators return None.
Specialized string and bytes comparisons
crates/vm/src/builtins/str.rs, crates/vm/src/bytes_inner.rs, crates/vm/src/frame.rs, extra_tests/snippets/operator_comparison.py
String and bytes comparison paths use direct equality checks and retain ordering fallbacks. Tests cover strings, bytes, bytearrays, Unicode, prefixes, and mixed operand types.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 13c12

This localized optimization changes equality handling for strings and bytes while preserving ordering behavior; no actionable merge-blocking risk remains after normal review and checks.

Suggested reviewers: shaharnaveh, joshuamegnauth54

🚥 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: using equality checks instead of ordering for str and bytes comparisons.
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.

PyStr's comparison, PyBytesInner's, and the specialized CompareOpStr
instruction all answered == and != by taking Ord::cmp of the two buffers
and asking whether the result was Equal. An ordering has to read the
bytes: it memcmps the common prefix even where the lengths already settle
the question. CompareOpStr bypasses the Comparable slot, so it had also
lost the identity shortcut that slot takes, and a string compared with
itself was read end to end.

Add PyComparisonOp::eval_eq, which settles Eq and Ne from an equality test
and leaves an ordering operator to the caller, and answer through it in the
three places: slice equality checks the length first, and CompareOpStr
answers an object compared with itself the way the slot it specializes does.

n=1,000,000, per comparison:

                                      before      after
    s == s (the very same object)    23.21us     0.16us
    s == a string one shorter        24.63us     0.17us
    b == bytes one shorter           25.28us     0.20us
    ba == bytearray one shorter      27.68us     0.23us
    s == an equal, distinct string   23.78us    24.50us
    s <  an equal string             29.87us    25.07us

Assisted-by: Claude
@youknowone
youknowone force-pushed the str-equality-not-ordering branch from 9c04b06 to 13c127a Compare August 15, 2026 01:44
@youknowone

Copy link
Copy Markdown
Member Author

@coderabbitai full review

The incremental reviews so far only saw the diff between force-pushes and skipped the files as unchanged, so this asks for a pass over the complete change.

commented by Claude

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

@youknowone: I will review the complete current change set for PR #8531.

✅ Action performed

Full review finished.

@youknowone
youknowone merged commit 833a2ba into RustPython:main Aug 15, 2026
27 checks passed
@youknowone
youknowone deleted the str-equality-not-ordering branch August 15, 2026 09:23
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