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) 22 days ago
- Description updated (diff)
Updated by byroot (Jean Boussier) 22 days 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) 22 days ago
Makes sense, I'll benchmark it and report back.
Updated by byroot (Jean Boussier) 22 days 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 | - |
Updated by himura467 (Akito Shitara) 21 days ago
· Edited
- File string_subseq.yml string_subseq.yml added
- File str_subseq_memory.rb str_subseq_memory.rb added
On my machine (arm64 macOS), the proposed patch as-is gives:
| len | slot used | speed vs master |
|---|---|---|
| 50 | 80B | 1.34x faster |
| 100 | 128B | 1.26x faster |
| 200 | 256B | 1.07x faster |
| 250 | 512B | 1.31x slower |
| 500 | 640B | 1.28x slower |
| 999 | 1024B | 1.83x slower |
| 1000 | shared | 1.00x |
Copying wins while the result fits in a slot of 256 bytes or less, and loses from the 512B slot up. The slot size seems to matter more than the memcpy itself.
Memory confirms your concern. Retaining 200,000 slices, master uses 7.6MB (a 40B shared header each) at every size, while the proposed patch uses up to 195MB (1024B per slice at len 999).
If we only copy below a cutoff instead, for example:
const size_t embed_size = rb_str_embed_size(len, termlen);
if (embed_size <= STR_SUBSEQ_EMBED_MAX_SIZE && rb_gc_size_allocatable_p(embed_size)) {
then the results become, with a frozen source:
| len | speed vs master |
|---|---|
| 50 | 1.30x faster |
| 100 | 1.31x faster |
| 200 | 1.11x faster |
| 231 | 1.06x faster |
| 232 | within noise, shared as before |
| 250 | within noise, shared as before |
| 500 | within noise, shared as before |
| 1000 | within noise, shared as before |
Worst-case retained memory drops from 25.6x to 6.4x, and everything above the cutoff behaves exactly like master.
I also benchmarked the non-frozen case by building a fresh unshared source every iteration. As you suspected it favors copying even more, since the embedded path skips the frozen-root allocation: 1.15-1.20x faster below the cutoff, unchanged above it.
The benchmark file is attached, along with the script I used for the memory measurements.
Updated by byroot (Jean Boussier) 21 days ago
The slot size seems to matter more than the memcpy itself.
Right, that is often the case in micro-benchmarks, larger pools have proportionately less slots, hence end up triggering GC more often tanking performance. This isn't always a good reflection of performance in larger benchmark / real programs.
Aside from these comments on the potential regression, ultimately, just like with shareable middle substring, I think such a change will speedup some programs, and slow down others.
Sometimes the slice into another string will be long lived or immediately mutated and copying is preferable.
But sometimes it is very short lived and sharing would have been preferable.
Since the caller doesn't have control over whether Ruby will use copy or sharing, ultimately we're limited to heuristics, which by definition won't ever be perfect.
I would be in favor of letting the caller chose, as I'm frequently working with performance sensitive string parsing code, buffers, etc, but I wouldn't expect such design to be accepted.
Updated by byroot (Jean Boussier) 21 days ago
Here's a benchmark to demonstrate the cost of allocating larger slots, the difference being with how often GC triggers (NB: using Ruby 4.1.0dev sizes):
require 'benchmark/ips'
require 'objspace'
def make_size(size)
klass = Class.new
obj = klass.new
((size - 16) / 8).times do |i|
obj.instance_variable_set("@ivar_#{i}", i)
end
if ObjectSpace.memsize_of(klass.new) != size
raise "Failed to create size #{size}, got: #{ObjectSpace.memsize_of(klass.new)}"
end
klass
end
SIZES = [
32, 40, 64, 128, 160, 256, 512, 640, 1024,
].to_h { |s| [s, make_size(s) ]}
Benchmark.ips do |x|
SIZES.each do |size, klass|
x.report("#{size}") { klass.new }
end
x.compare!(order: :baseline)
end
def gc_count
before = GC.count
yield
GC.count - before
end
def alloc(klass, count)
count /= 10
while count > 0
count -= 1
klass.new
klass.new
klass.new
klass.new
klass.new
klass.new
klass.new
klass.new
klass.new
klass.new
end
end
puts "GC count for 10M allocations"
SIZES.each do |size, klass|
count = gc_count { alloc(klass, 10_000_000) }
puts "size #{size}: #{count}"
end
ruby 4.1.0dev (2026-07-08T23:30:28Z master 583668d5ed) +YJIT +PRISM [arm64-darwin25]
Warming up --------------------------------------
32 3.184M i/100ms
40 2.994M i/100ms
64 3.045M i/100ms
128 2.854M i/100ms
160 2.703M i/100ms
256 2.157M i/100ms
512 1.864M i/100ms
640 1.765M i/100ms
1024 1.505M i/100ms
Calculating -------------------------------------
32 39.290M (± 2.2%) i/s (25.45 ns/i) - 197.437M in 5.025088s
40 37.197M (± 1.9%) i/s (26.88 ns/i) - 188.653M in 5.071767s
64 35.340M (± 0.5%) i/s (28.30 ns/i) - 179.672M in 5.084066s
128 32.319M (± 1.3%) i/s (30.94 ns/i) - 162.664M in 5.033113s
160 30.155M (± 0.5%) i/s (33.16 ns/i) - 151.358M in 5.019408s
256 23.283M (± 1.7%) i/s (42.95 ns/i) - 116.478M in 5.002771s
512 19.271M (± 3.6%) i/s (51.89 ns/i) - 96.903M in 5.028366s
640 17.815M (± 1.2%) i/s (56.13 ns/i) - 90.028M in 5.053602s
1024 14.912M (± 1.0%) i/s (67.06 ns/i) - 75.247M in 5.045902s
Comparison:
32: 39290346.8 i/s
40: 37196774.6 i/s - 1.06x slower
64: 35340193.1 i/s - 1.11x slower
128: 32318863.3 i/s - 1.22x slower
160: 30154536.2 i/s - 1.30x slower
256: 23282729.1 i/s - 1.69x slower
512: 19271205.8 i/s - 2.04x slower
640: 17814560.6 i/s - 2.21x slower
1024: 14912457.7 i/s - 2.63x slower
GC count for 10M allocations
size 32: 67
size 40: 79
size 64: 133
size 128: 195
size 160: 239
size 256: 396
size 512: 781
size 640: 997
size 1024: 1604
Updated by byroot (Jean Boussier) 21 days ago
Another thing that could be interesting to investigate at, is why fluentd benefit so much from this change. I suspect it may be currently creating shared strings that are almost immediately unshared. A hunch is that it may be shrinking a buffer with something like:
Updated by himura467 (Akito Shitara) 9 days ago
byroot (Jean Boussier) wrote in #note-8:
Another thing that could be interesting to investigate at, is why fluentd benefit so much from this change. I suspect it may be currently creating shared strings that are almost immediately unshared. A hunch is that it may be shrinking a buffer with something like:
The benchmark only drives the LTSV parser, which never calls byteslice:
https://github.com/fluent/fluentd/blob/v1.19.3/lib/fluent/plugin/parser_ltsv.rb#L40-L42
pair is not frozen, and pair.split(":", 2) takes the value as a suffix. Since the source is not frozen, sharing allocates a frozen root as well as the child, while copying allocates only the child, so each value saves one allocation. Nothing is shared and then unshared.
I should also say that this benchmark is not stable on my machine. The spread between repeated runs is wider, so I would rely on the allocation count rather than the timing.
Updated by byroot (Jean Boussier) 9 days ago
Oh nice find, I would never have suspected split but it makes sense. Especially a nested split like this.
Updated by himura467 (Akito Shitara) 9 days ago
byroot (Jean Boussier) wrote in #note-6:
This isn't always a good reflection of performance in larger benchmark / real programs.
Agreed, I looked for real libraries with the same shape as the fluentd case.
Net::HTTPResponse#read_headers is one:
https://github.com/ruby/ruby/blob/master/lib/net/http/response.rb#L188
The source is not frozen, so as with fluentd each value that takes the shared path allocates a frozen root that copying avoids.
How many values that reaches depends on real header lengths, so I collected response headers from five sites. The last two columns are how many of the shared values each cutoff would copy instead:
| site | values | embedded on master | shared on master | copied at 128 | copied at 256 |
|---|---|---|---|---|---|
| https://www.ruby-lang.org | 20 | 12 | 8 | 8 | 8 |
| https://bugs.ruby-lang.org | 21 | 9 | 12 | 8 | 11 |
| https://github.com | 18 | 6 | 12 | 7 | 10 |
| https://www.wikipedia.org | 22 | 7 | 15 | 12 | 15 |
| https://www.cloudflare.com | 28 | 10 | 18 | 9 | 11 |
| total | 109 | 44 | 65 | 44 | 55 |
A 128 cutoff covers 44 of the 65 shared values and a 256 cutoff covers 55. The 11 in between include six Set-Cookie.
Falcon is a case that gets nothing. protocol-http1 is pure Ruby and the values do move from shared to embedded, but the allocation count is unchanged, because it parses with a regex:
https://github.com/socketry/protocol-http1/blob/v0.39.0/lib/protocol/http1/connection.rb#L504-L505
line.match already allocates a frozen copy of the line for the MatchData, so the source is frozen by the time the capture is sliced and sharing costs one allocation, the same as copying. The saving here is the frozen root, and regex parsing has already paid for it.
So the benefit tracks the parser rather than the format. fluentd's LTSV parser and Net::HTTP both split a string that is not frozen, while Falcon matches a regex.
Either cutoff seems defensible to me on this evidence. 256 covers 55 of the 65 shared values against 44, while 128 would halve the 6.4x worst case retained memory I reported in #note-5. I do not have a strong argument for one over the other.
For Falcon I ran protocol-http1's HEADER regex and read loop directly rather than starting a server.
Updated by himura467 (Akito Shitara) 9 days ago
byroot (Jean Boussier) wrote in #note-6:
Since the caller doesn't have control over whether Ruby will use copy or sharing, ultimately we're limited to heuristics, which by definition won't ever be perfect.
Agreed, and I should have said so alongside the numbers. I did not mean the header data as an argument that either cutoff is the correct one, since there isn't one. It is only a lens for choosing, and the choice stays a judgement call.
Updated by byroot (Jean Boussier) 9 days ago
Based on the data you've provided, I'm pretty convinced that bumping the limit to at least ~128B would make a lot of sense.
We could also have a different limit depending on the status of the source string. If the source string is already frozen or shared, creating an extra shared string is much cheaper, so we could keep the current embeddable size in these case, but otherwise it would be ~128B (or close, it doesn't have to perfectly fit an existing slot size, but might as well).
Updated by himura467 (Akito Shitara) 9 days ago
Sounds good, I will go with that.