Project

General

Profile

Actions

Feature #22186

open

Increase the embeddable size limit for substrings created by `str_subseq()`

Feature #22186: Increase the embeddable size limit for substrings created by `str_subseq()`

Added by himura467 (Akito Shitara) 22 days ago. Updated 9 days ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:125966]

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

output_001.txt (7.76 KB) output_001.txt himura467 (Akito Shitara), 07/08/2026 01:55 AM
output_001-ratio-current.png (184 KB) output_001-ratio-current.png himura467 (Akito Shitara), 07/08/2026 01:56 AM
output_002.txt (14.9 KB) output_002.txt himura467 (Akito Shitara), 07/08/2026 01:56 AM
string_subseq.yml (418 Bytes) string_subseq.yml himura467 (Akito Shitara), 07/09/2026 03:52 AM
str_subseq_memory.rb (1.25 KB) str_subseq_memory.rb himura467 (Akito Shitara), 07/09/2026 03:52 AM

Updated by himura467 (Akito Shitara) 22 days ago Actions #1

  • Description updated (diff)

Updated by byroot (Jean Boussier) 22 days ago Actions #2 [ruby-core:125971]

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 Actions #3 [ruby-core:125972]

Makes sense, I'll benchmark it and report back.

Updated by byroot (Jean Boussier) 22 days ago · Edited 1Actions #4 [ruby-core:125973]

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 Actions #5 [ruby-core:125980]

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:

#if !defined STR_SUBSEQ_EMBED_MAX_SIZE
# define STR_SUBSEQ_EMBED_MAX_SIZE 256
#endif
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 Actions #6 [ruby-core:125983]

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 Actions #7 [ruby-core:125984]

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 Actions #8 [ruby-core:125985]

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:

@buffer = @buffer.byteslice(@pos..-1)

Updated by himura467 (Akito Shitara) 9 days ago Actions #9 [ruby-core:126127]

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:

@buffer = @buffer.byteslice(@pos..-1)

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 Actions #10 [ruby-core:126128]

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 Actions #11 [ruby-core:126129]

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 Actions #12 [ruby-core:126130]

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 Actions #13 [ruby-core:126131]

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 Actions #14 [ruby-core:126132]

Sounds good, I will go with that.

Actions

Also available in: PDF Atom