Feature #22186
openIncrease the embeddable size limit for substrings created by `str_subseq()`
Description
Summary¶
str_subseq() unnecessarily limits how large a sharable middle substring can be while still being embedded into the new RString. Substrings roughly 24 to 1000 bytes long (on a 64-bit build) always take the shared path, even though variable width allocation (VWA, Feature #18239) already supports embedding objects of that size.
I propose sizing the allocation to the actual substring length via STR_EMBEDDABLE_P() / str_alloc_embed(), the same pattern used by other allocation sites in string.c, instead of allocating through str_alloc_heap() and checking whether the substring fits in the smallest heap slot class.
Background¶
For a sharable substring (per SHARABLE_SUBSTRING_P), str_subseq() either embeds the bytes directly into the new RString, or shares the parent's heap buffer, which keeps the parent alive. Since commit 132f097149, the decision has been:
str2 = str_alloc_heap(rb_cString);
if (str_embed_capa(str2) >= len + termlen) {
// embed
}
else {
// share
}
str_alloc_heap() always allocates the default sizeof(struct RString) slot regardless of len, so str_embed_capa(str2) is capped at roughly 23 bytes. Meanwhile the default GC's size pools go up to 1024 bytes on 64-bit builds, and other allocation sites (str_enc_new behind rb_str_new, rb_str_buf_new, str_new_frozen_buffer, rb_str_times, ...) already pick the right-sized slot via STR_EMBEDDABLE_P() / str_alloc_embed().
Proposed change¶
Draft PR: https://github.com/ruby/ruby/pull/17723
Check STR_EMBEDDABLE_P(len, termlen) up front and allocate with str_alloc_embed(); only allocate the STR_NOEMBED heap object on the sharing branch. Behavior is unchanged for substrings that aren't sharable or that reach the end of the parent's buffer. Only the threshold at which a sharable middle substring switches from embedding to sharing grows.
Benchmark results¶
Using ruby-bench, this branch vs current master (arm64-darwin25, +PRISM). Full results are attached as output_001.txt, with a per-benchmark ratio chart in output_001-ratio-current.png. Top 3 and bottom 3 by ratio (master/branch, higher is better):
| bench | master (ms) | current (ms) | ratio |
|---|---|---|---|
| fluentd | 258.6 | 230.8 | 1.120 |
| ruby-json | 154.1 | 150.2 | 1.026 |
| liquid-c | 31.2 | 30.6 | 1.022 |
| graphql-native | 172.0 | 174.9 | 0.983 |
| activerecord | 148.7 | 153.0 | 0.972 |
| psych-load | 1244.9 | 1291.4 | 0.964 |
Note that most except fluentd varies between runs. The fluentd improvement reproduces across runs, while the entries in the bottom 3 do not. So the few 2-4% slowdowns may be noise, but I'd like to verify them with more runs and machines before treating this as risk-free.
Relation to Feature #19315¶
#19315 (sharable middle substrings) addresses the same underlying problem: middle substrings copy unnecessarily.
The branch benchmarked in https://bugs.ruby-lang.org/issues/19315#note-52 actually included this embed size change as one of its commits, in addition to enabling SHARABLE_MIDDLE_SUBSTRING. To isolate the two, I benchmarked a variant of that branch with only the embed size change reverted (rstring-raw-ptr-reverted), alongside this proposal, against the same master. Full results are attached as output_002.txt:
| bench | master (ms) | this proposal (ms) | #19315 without this change (ms) |
|---|---|---|---|
| fluentd | 256.9 | 207.2 (x1.240) | 255.6 (x1.005) |
The clear fluentd improvement seen in note-52 reproduces with this change alone, and disappears when this change is reverted from the #19315 branch. So the practical win observed on ruby-bench so far is attributable to the larger embed threshold rather than to SHARABLE_MIDDLE_SUBSTRING itself.
Beyond that, this change is much narrower:
- A self-contained fix to one function, reusing the VWA embedding infrastructure already battle-tested elsewhere in
string.c. - No API/ABI implications;
RSTRING_PTR()'s\0-termination guarantee is untouched. - No memory-retention risk, since embedding copies the bytes instead of referencing the parent.
The two are orthogonal (#19315 still helps for slices too large to embed, as its micro-benchmarks show), but this smaller fix seems worth landing first on its own merits.
Files
Updated by himura467 (Akito Shitara) about 21 hours ago
- Description updated (diff)
Updated by byroot (Jean Boussier) about 17 hours ago
My worry about this is that there might be a cutoff point where copying the bytes in a new embedded string is more costly than going the shared route.
There's also the memory usage concern.
Only copying when we fit in the smallest slot is definitely not good, but I think here a micro-benchmark with various sizes (50, 100, 250, 500, 1000) would be warranted.
Updated by himura467 (Akito Shitara) about 17 hours ago
Makes sense, I'll benchmark it and report back.
Updated by byroot (Jean Boussier) about 17 hours ago
· Edited
https://github.com/ruby/ruby/commit/48f6f0c272111693ce8e4949fbd4e76f64d906af
Note that the source string is frozen so we always only do one allocation. In real world code if the string isn't frozen and isn't shared already, we allocate not one but two strings, which would change the results.
But it's harder to benchmark accurately.
compare-ruby: ruby 4.1.0dev (2026-07-08T03:48:16Z master 01c5b2e) +PRISM [arm64-darwin25]
built-ruby: ruby 4.1.0dev (2026-07-08T06:40:02Z str-subseq-embed-fix f711929021) +PRISM [arm64-darwin25]
last_commit=WIP
warming up......
| compare-ruby | built-ruby | |
|---|---|---|
| size1 | 54.923M | 58.324M |
| - | 1.06x | |
| size23 | 36.915M | 52.254M |
| - | 1.42x | |
| size100 | 34.029M | 43.905M |
| - | 1.29x | |
| size250 | 34.505M | 32.591M |
| 1.06x | - | |
| size500 | 37.446M | 25.670M |
| 1.46x | - | |
| size1000 | 35.801M | 32.208M |
| 1.11x | - |