Skip to content

str/bytes: receiver identity for whole-span cuts - #825

Merged
youknowone merged 2 commits into
mainfrom
ec-wiring
Jul 27, 2026
Merged

str/bytes: receiver identity for whole-span cuts#825
youknowone merged 2 commits into
mainfrom
ec-wiring

Conversation

@youknowone

@youknowone youknowone commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Two parity fixes, rebased onto origin/main (#830).

1. Hand back the receiver when a cut spans it whole

ll_stringslice_startstop (rstr.py:867-869) returns the source string unchanged for start == 0 and stop >= len, so a string method that cuts a piece out of its receiver shares that receiver's storage when the cut spans it whole, and is_w (unicodeobject.py:110-111, bytesobject.py:34-35) reports the two identical. descr_replace (unicodeobject.py:1175-1176) returns self on zero replacements by the same reasoning, format_string (newformat.py:602-608) wraps the receiver's own storage once the spec parses to the defaults, and descr_str (unicodeobject.py:333-337) returns self for an exact str.

pyre allocated a fresh object at each of those sites, so 31 identity cases read False where PyPy reads True: s[:], format(s, ""), f"{s}", f"{s!s}", str.__format__(s, ""), s.strip(), s.split(',')[0], s.splitlines()[0], s.replace('z', 'z') and the bytes counterparts.

Adds w_str_cut / cut_bytes_like, which return the receiver when the piece's byte count matches it and the receiver is an exact str / bytes. A subclass cuts to a fresh base object and a bytearray always gets a fresh mutable one, matching is_w's user_overridden_class rejection and _new. Cuts only: a transform can preserve the byte count while changing the bytes, and those have no upstream identity shortcut.

wtf8_replace and replace_bytes now return the (res, replacements) pair replace_count (rstring.py:220-309) does, so replace keys its shortcut on the count rather than on the resulting bytes. format_value_dispatch_w is the object-returning PyObject_Format entry; format(), FORMAT_SIMPLE/FORMAT_WITH_SPEC and str.__format__ take it, and convert_value honours descr_str for !s.

2. Split removeprefix's slice arm from removesuffix's empty-argument arm

The whole-span shortcut belongs to ll_stringslice_startstop alone. A one-bound s[start:] resolves to ll_stringslice_startonly (rstr.py:857-858), which calls _ll_stringslice directly and always builds a fresh string, start == 0 included.

descr_removeprefix (stringmethods.py:875-880, unicodeobject.py:1497-1505) slices with that one-bound form, so an empty prefix allocates and b.removeprefix(b"") is b is False; only the no-match arm rewraps the receiver's storage. Commit 1 routed the result through cut_bytes_like, which collapsed the two arms and returned the receiver — a regression this commit fixes before it ever reaches main.

descr_removesuffix (stringmethods.py:882-889, unicodeobject.py:1507-1517) guards its slice arm with if suffix and ..., so an empty suffix reaches the rewrap arm and s.removesuffix("") is s is True. The str side matched on strip_suffix alone and took the slice arm — a pre-existing gap, also fixed.

Both helper docs now carry the one-bound caveat: check which RPython slice helper the upstream arm resolves to before routing a new call site through them.

Audited clean as two-bound: full slice, strip/lstrip/rstrip, split/rsplit (separator and whitespace), splitlines, partition's no-match arm, replace's zero-count arm.

Rebase note

Rebased onto #830. One conflict, one region, in getitem_str: main had replaced the Vec<CodePoint> materialisation with O(1) code-point indexing through _index_to_byte (unicodeobject.py:1251), while this branch added the whole-span early return. Both are orthodox and independent, so the resolution keeps both and only adapts the comparison to main's len becoming a usize.

Known remaining divergence

s.encode().decode() is s stays False: PyPy shares one RPython string between the str and the bytes, while W_UnicodeObject.value is a per-object *mut Wtf8Buf. Closing it needs a representation change.

Verification

Oracle is pypy3 7.3.20 (3.11.13). Negative controls are in the probes so over-application is visible, and all held: str/bytes subclasses stay is-False and yield base types; bytearray is False everywhere and does not alias; "%s" % s, "{}".format(s), s.lower() stay False.

Run against the pre-rebase tree, with the same two commits:

  • 30-case degenerate-argument probe (removeprefix/removesuffix/strip('')/split(None,0)/replace('','') × str/bytes/bytearray/subclass): 30/30 match the oracle on both backends, was 2 mismatches before commit 2
  • full identity probes: 32 divergences → 1 (the encode/decode case above)
  • check.py 331/331 on dynasm and cranelift, each at default and PYRE_FBW_MULTIFRAME=1, run serially
  • cargo test -p pyre-jit-trace -p pyre-jit --features dynasm: 319 passed, 0 failed
  • getframe/traceback discriminators 9/9 on both backends
  • cargo fmt --all --check clean

The same suite is being re-run against the rebased tree.

Work considered and rejected

A third commit routed the seven sites that box a code point out of a string (str slice and index, next() on a str seq-iterator, sequence_getitem, the seq-iterator helper, GET_ITER, chr()) from w_str_from_wtf8 — which is w_str_from_wtf8_immortal byte for byte — to w_str_from_wtf8_managed. It is not in this branch, because it fails on both axes: with PYRE_GC_INTERP=1, for ch in s over 4M code points ran 2.71s before and 7.48s after (min of three); at 40M, 74.2s before and over 120s after; peak RSS did not improve to pay for it (501 MB → 482 MB).

Cause: w_str_from_wtf8_managed calls note_alloc, so the safepoint runs a full old-gen mark-sweep every COLLECT_THRESHOLD (65536) boxed characters. That threshold is documented against "~24-40 B per int/float" (gc_interp.rs:107-109) and does not fit a workload boxing millions of one-character strings. The routing becomes worthwhile only once the safepoint throttles on allocation size rather than count.

Worth recording separately: the underlying interpreter-object leak has two causes and that commit addressed only the second. PYRE_GC_INTERP is off by default on native (gc_interp.rs:118-130), so w_str_from_wtf8_managed falls back to the immortal path and every interpreter-allocated object is immortal regardless. The reclamation machinery itself works — RSS sampled once a second over s.lower() goes flat at ~143 MB with the flag on, and climbs past 499 MB with it off.

Note for reviewers

PyPy-specific identity semantics currently have no home in the test suites — check.py gates correctness on PyPy output, but check_synthetic.py baselines on CPython, and the two disagree on several of these cases (s.removeprefix(""), s.replace("",""), s.split(None,0)[0], …). The existing removeprefix/removesuffix tests assert equality only, never identity, which is why the regression went unnoticed.

authored by Claude

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

String and bytes operations now preserve backing storage for cut results and return exact immutable receivers for unchanged operations. Formatting and conversion paths also avoid redundant string allocation.

Changes

String result construction

Layer / File(s) Summary
String cut construction and operations
pyre/pyre-object/src/unicodeobject.rs, pyre/pyre-interpreter/src/type_methods.rs
String splitting, stripping, line splitting, and replacement use cut-based results or return the original exact string when unchanged.

Bytes result construction

Layer / File(s) Summary
Bytes cut construction and replacement
pyre/pyre-interpreter/src/typedef.rs
Bytes-like operations use cut-based construction, and no-op replacement returns the original exact immutable bytes object.

Identity fast paths

Layer / File(s) Summary
Slicing, conversion, and formatting shortcuts
pyre/pyre-interpreter/src/baseobjspace.rs, pyre/pyre-interpreter/src/runtime_ops.rs, pyre/pyre-interpreter/src/builtins.rs, pyre/pyre-interpreter/src/type_methods.rs
Unchanged exact strings and bytes are returned directly, and formatting dispatch now returns Python string objects without redundant wrapping.

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

Possibly related PRs

Suggested reviewers: lifthrasiir

Poem

I nibbled through cuts where new strings once grew,
Kept bytes on their branches, intact and true.
No-op replacements now quietly stay,
Formatting hops less on its way.
A pleased little rabbit says: reuse hooray!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: preserving receiver identity for whole-span string/bytes cuts.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ec-wiring

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.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

🤖 Codex parity review

Static analysis of this diff vs the local RPython/PyPy sources (commit 041402c).
Updated: 2026-07-27T12:54:13.003Z

Files in the reviewed diff
pyre/pyre-interpreter/src/baseobjspace.rs
pyre/pyre-interpreter/src/builtins.rs
pyre/pyre-interpreter/src/runtime_ops.rs
pyre/pyre-interpreter/src/type_methods.rs
pyre/pyre-interpreter/src/typedef.rs
pyre/pyre-object/src/unicodeobject.rs

1. Regressions to PyPy parity introduced by this patch

  • pyre/pyre-interpreter/src/baseobjspace.rs:1659 ↔ pypy/objspace/std/unicodeobject.py:1022: whole exact-str slicing now returns obj unconditionally. PyPy reaches _unicode_sliced; its translated ll_stringslice_startstop deliberately skips the return s1 reuse path while jitted (rpython/rtyper/lltypesystem/rstr.py:863), producing distinct storage.

  • pyre/pyre-interpreter/src/baseobjspace.rs:1736 ↔ pypy/objspace/std/stringmethods.py:135: whole exact-bytes slicing now returns obj unconditionally. PyPy’s _sliced(... selfvalue, start, stop, self) uses the same JIT-sensitive ll_stringslice_startstop behavior (rpython/rtyper/lltypesystem/rstr.py:862), so this regresses JIT-mode identity parity.

  • pyre/pyre-object/src/unicodeobject.rs:280 ↔ rpython/rtyper/lltypesystem/rstr.py:863: w_str_cut unconditionally aliases the receiver for an equal-length whole cut. Added callers in pyre/pyre-interpreter/src/type_methods.rs:820, :851, :888, :962, :1068, and :5217 therefore incorrectly preserve identity in JIT-mode split, rsplit, strip-family, and splitlines; PyPy’s corresponding paths slice/create wrappers at pypy/objspace/std/unicodeobject.py:983, :1002, :1393, and :1635.

  • pyre/pyre-interpreter/src/typedef.rs:16862 ↔ rpython/rtyper/lltypesystem/rstr.py:863: cut_bytes_like has the same unconditional whole-cut reuse. Its new uses in pyre/pyre-interpreter/src/typedef.rs:16921, :17202, and :17774 regress JIT-mode identity for bytes strip-family, split/rsplit, and splitlines; PyPy slices through pypy/objspace/std/stringmethods.py:711, :586, and :627.

2. Other mismatches introduced by this patch

None.

3. Pre-existing mismatches (already present before this patch)

  • pyre/pyre-interpreter/src/baseobjspace.rs:3852 ↔ pypy/objspace/std/unicodeobject.py:110: Pyre treats distinct exact strings longer than one code point as never identical, whereas PyPy treats separately wrapped strings as identical when they share UTF-8 storage. The pre-patch allocation model copied storage, so Pyre could not represent this PyPy identity case.

  • pyre/pyre-interpreter/src/baseobjspace.rs:3832 ↔ pypy/objspace/std/bytesobject.py:35: the same pre-existing limitation applies to exact bytes longer than one byte: PyPy can preserve is_w through shared backing storage, while Pyre’s separately allocated bytes cannot.

4. Structural adaptations

  • pyre/pyre-object/src/unicodeobject.rs:276 ↔ pypy/objspace/std/unicodeobject.py:1050: returning the existing Rust object is a structural substitute for PyPy’s distinct W_UnicodeObject wrapper sharing an RPython string buffer. This is reasonable for non-jitted execution because it preserves observable is_w, but it must retain PyPy’s jit.we_are_jitted() distinction rather than applying reuse unconditionally.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

https://github.com/youknowone/pyre/blob/aad63bc9d3a228dd3e64914fe5a266101efe92a0/pyre-object/src/unicodeobject.rs#L238
P1 Badge Preserve shared storage instead of returning the wrapper

For every whole cut, this returns the original W_UnicodeObject itself (and cut_bytes_like repeats the shortcut), whereas the cited PyPy path constructs a new wrapper whose backing string aliases the receiver and lets is_w/immutable_unique_id derive identity from that shared storage. This strengthens storage identity into wrapper-pointer identity and bypasses the representation change needed for structural parity; preserve shareable backing storage and teach the identity/UID paths about it instead of collapsing the two wrappers.

AGENTS.md reference: AGENTS.md:L194-L196

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@youknowone youknowone changed the title str/bytes: hand back the receiver when a cut spans it whole str/bytes: receiver identity for whole-span cuts, and collectable code-point boxing Jul 27, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e04656629c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +245 to +248
if piece.len() == unsafe { w_str_get_wtf8(recv) }.len()
&& unsafe { is_exact_type(recv, &STR_TYPE) }
{
return recv;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace the receiver shortcut with shared backing storage

Upstream creates a new W_UnicodeObject around the sliced RPython string and derives is_w from shared _utf8 storage; returning recv here instead hard-codes the desired identity result into selected call sites. This requires every slicing method to be manually classified and leaves operations that share storage through other paths, such as the acknowledged encode/decode case, structurally impossible to represent. Model the shared backing storage and make is_w compare it rather than returning the original wrapper; the paired cut_bytes_like shortcut needs the same treatment.

AGENTS.md reference: AGENTS.md:L194-L196

Useful? React with 👍 / 👎.

@youknowone youknowone changed the title str/bytes: receiver identity for whole-span cuts, and collectable code-point boxing str/bytes: receiver identity for whole-span cuts Jul 27, 2026
`ll_stringslice_startstop` (rstr.py:867-869) returns the source string
unchanged for `start == 0 and stop >= len`, so every string method that cuts
a piece out of its receiver shares that receiver's storage when the cut spans
it whole, and `is_w` (unicodeobject.py:110-111, bytesobject.py:34-35) reports
the two identical.  `descr_replace` (unicodeobject.py:1175-1176) returns
`self` on zero replacements by the same reasoning, `format_string`
(newformat.py:602-608) wraps the receiver's own storage once the spec parses
to the defaults, and `descr_str` (unicodeobject.py:333-337) returns `self`
for an exact `str`.

pyre allocated a fresh object at each of those sites, so 31 identity cases
read False where PyPy reads True: `s[:]`, `format(s, "")`, `f"{s}"`,
`f"{s!s}"`, `str.__format__(s, "")`, `s.strip()`, `s.split(',')[0]`,
`s.splitlines()[0]`, `s.replace('z', 'z')` and the bytes counterparts.

Adds `w_str_cut` / `cut_bytes_like`, which return the receiver when the
piece's byte count matches it and the receiver is an exact `str` / `bytes`.
A subclass cuts to a fresh base object and a `bytearray` always gets a fresh
mutable one, matching `is_w`'s `user_overridden_class` rejection and `_new`.
Both are for cuts only: a transform can preserve the byte count while
changing the bytes, and those have no upstream identity shortcut.

Routed through them: the str and bytes full slice, strip/lstrip/rstrip,
split/rsplit over both the separator and whitespace forms, splitlines,
partition's no-match arm, and removeprefix/removesuffix.

`wtf8_replace` and `replace_bytes` now return the `(res, replacements)` pair
`replace_count` (rstring.py:220-309) does, so replace keys its shortcut on
the count rather than on the resulting bytes -- `s.replace('a', 'a')` still
builds a new string.

`format_value_dispatch_w` is the object-returning `PyObject_Format` entry;
`format()`, FORMAT_SIMPLE/FORMAT_WITH_SPEC and `str.__format__` take it, and
`convert_value` honours `descr_str` for the `!s` conversion.  `format()` also
stops returning an immortal string for a dynamic result.

`s.encode().decode() is s` stays False: PyPy shares one RPython string
between the str and the bytes, while `W_UnicodeObject.value` is a per-object
`*mut Wtf8Buf`.

Assisted-by: Claude
…rgument arm

The whole-span shortcut belongs to `ll_stringslice_startstop` alone
(rstr.py:867-869).  A one-bound `s[start:]` resolves to
`ll_stringslice_startonly` (rstr.py:857-858), which calls `_ll_stringslice`
directly and always builds a fresh string, `start == 0` included.

`descr_removeprefix` (stringmethods.py:875-880, unicodeobject.py:1497-1505)
slices with that one-bound form, so an empty prefix allocates and
`b.removeprefix(b"") is b` is False; only the no-match arm rewraps the
receiver's own storage.  Routing the result through `cut_bytes_like` collapsed
the two arms into one and returned the receiver for an empty prefix.

`descr_removesuffix` (stringmethods.py:882-889, unicodeobject.py:1507-1517)
guards its slice arm with `if suffix and ...`, so an empty suffix reaches the
rewrap arm and `s.removesuffix("") is s` is True.  The str side matched on
`strip_suffix` alone, which succeeds for an empty suffix, and took the slice
arm.

Both now carry the upstream two-arm shape.  The bytes side of removesuffix
already had the guard.

`w_str_cut` and `cut_bytes_like` document the one-bound caveat: check which
RPython slice helper the upstream arm resolves to before routing a new call
site through them.

Assisted-by: Claude

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 041402cfdd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1654 to +1658
if step == 1
&& start == 0
&& slicelength == len as i64
&& pyre_object::pyobject::is_exact_type(obj, &pyre_object::pyobject::STR_TYPE)
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the shortcut to the BinarySlice path

When the bounds are dynamic but normalize to the whole string (for example, start = 0; stop = len(s); s[start:stop]), the compiler emits BinarySlice, so this new shortcut is bypassed: runtime_ops::binary_slice_values handles str directly at lines 717-739 and still always allocates a fresh string. Consequently the common BINARY_SUBSCR path and the dedicated opcode now disagree, and the dynamic expression still reports False for is s; route the str arm of binary_slice_values through the same whole-span behavior.

AGENTS.md reference: AGENTS.md:L194-L195

Useful? React with 👍 / 👎.

@youknowone
youknowone merged commit daca722 into main Jul 27, 2026
18 of 19 checks passed
@youknowone
youknowone deleted the ec-wiring branch July 27, 2026 14:19
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