Bug #18599
closed`vm->loaded_features_snapshot` became very inefficient in Ruby 3.1
Description
A change in how shared arrays behave has caused a fairly major performance regression in Kernel.require
. In short on every single call, a full copy of $LOADED_FEATURES
is made.
In our app this causes over 6GiB worth of useless allocations.
Reproduction¶
# frozen_string_literal: true
require "objspace"
files = 5.times.map { |i| "/tmp/foo-#{i}.rb" }
files.each { |f| File.write(f, "") }
require files.shift
4.times { GC.start }
generation = GC.count
ObjectSpace.trace_object_allocations do
files.each { |f| require f }
end
ObjectSpace.dump_all(output: File.open("/tmp/allocated.heap", "w+"), since: generation)
ObjectSpace.dump_all(output: File.open("/tmp/full.heap", "w+"))
If you inspect the allocated.heap
, you'll notice 4 array, each increasingly bigger.
$ grep ARRAY /tmp/allocated.heap
{"address":"0x10fe2b3b8", "type":"ARRAY", "frozen":true, "length":92, "references":["0x10fcd3da8", "0x10fcca2a8", "0x10fcc21c0", ...], "file":"<internal:/opt/rubies/3.1.0/lib/ruby/3.1.0/rubygems/core_ext/kernel_require.rb>", "line":85, "method":"require", "generation":13, "memsize":776, "flags":{"wb_protected":true}}
...
{"address":"0x10fe2b688", "type":"ARRAY", "frozen":true, "length":91, "references":["0x10fcd3da8", "0x10fcca2a8", "0x10fcc21c0", ...], "file":"<internal:/opt/rubies/3.1.0/lib/ruby/3.1.0/rubygems/core_ext/kernel_require.rb>", "line":85, "method":"require", "generation":13, "memsize":768, "flags":{"wb_protected":true}}
...
{"address":"0x10fe2b958", "type":"ARRAY", "frozen":true, "length":89, "references":["0x10fcd3da8", "0x10fcca2a8", "0x10fcc21c0", ...], "file":"<internal:/opt/rubies/3.1.0/lib/ruby/3.1.0/rubygems/core_ext/kernel_require.rb>", "line":85, "method":"require", "generation":13, "memsize":752, "flags":{"wb_protected":true}}
...
{"address":"0x10fe2bc28", "type":"ARRAY", "frozen":true, "length":88, "references":["0x10fcd3da8", "0x10fcca2a8", "0x10fcc21c0", ...], "file":"<internal:/opt/rubies/3.1.0/lib/ruby/3.1.0/rubygems/core_ext/kernel_require.rb>", "line":85, "method":"require", "generation":13, "memsize":744, "flags":{"wb_protected":true}}
...
And if you lookup the addresses in full.heap
, it makes no doubt that it's $LOADED_FEATURES
.
Note however how the array is "frozen":true
, so it's not $LOADED_FEATURES
directly, but the shared array that backs vm->loaded_features
and vm->loaded_features_snapshot
.
More scoped reproduction¶
If we scope on shared arrays behavior specifically, we can get a much smaller reproduction:
array = 10.times.to_a
copy = array.dup
before = GC.stat(:total_allocated_objects)
copy << 1
p GC.stat(:total_allocated_objects) - before # 1 on Ruby 3.1, 0 on 3.0 and older
In short, back in 3.0 and older, appending to a shared array wouldn't "unshare" the array, but starting in 3.1 it now does.
The problem being that Kernel.require
heavily assumed this implementation detail:
static void
reset_loaded_features_snapshot(rb_vm_t *vm)
{
rb_ary_replace(vm->loaded_features_snapshot, vm->loaded_features);
}
rb_provide_feature(rb_vm_t *vm, VALUE feature)
{
//...
get_loaded_features_index(vm);
rb_ary_push(features, rb_fstring(feature));
features_index_add(vm, feature, INT2FIX(RARRAY_LEN(features)-1));
reset_loaded_features_snapshot(vm);
}
rb_provide_feature
first append to $LOADED_FEATURES
and then resets vm->loaded_features_snapshot
to be a shared copy of $LOADED_FEATURES
.
Updated by byroot (Jean Boussier) over 2 years ago
- Backport changed from 2.6: DONTNEED, 2.7: DONTNEED, 3.0: DONTNEED, 3.1: UNKNOWN to 2.6: DONTNEED, 2.7: UNKNOWN, 3.0: UNKNOWN, 3.1: UNKNOWN
Ok, so my assessment about shared arrays wasn't quite correct. What caused the difference between 3.0 and 3.1 here is http://github.com/ruby/ruby/commit/aeae6e2842e1702dfb89b8ae69b48c4f5f64c662
Previously the shared array would be force recycled immediately, which was hiding the problem, but it was already there.
With the help of @peterzhu2118 (Peter Zhu), we figured a fairly simple patch for this: https://github.com/ruby/ruby/pull/5593
Updated by byroot (Jean Boussier) over 2 years ago
I tested the patch against our app, it does solve the problem perfectly, and the time spent in GC during boot is noticeably reduced (~40% -> ~28%
).
Updated by byroot (Jean Boussier) over 2 years ago
- Status changed from Open to Closed
Applied in changeset git|b8f0dc59d52266d9fbfc039e2f4b0f727c62baa0.
rb_provide_feature: Prevent $LOADED_FEATURES from being copied
[Bug #18599]
vm->loaded_features
and vm->loaded_features_snapshot
both share the
same root. When a feature is pushed into loaded_features
, the sharing
is broken and loaded_features
is copied.
So an application requiring 1000 files, will allocate 1000 arrays of
increasing size, which is very wasteful.
To avoid this, we first clear the snapshot, so that loaded_features
can directly be pushed into.
Co-Authored-By: Peter Zhu peter.zhu@shopify.com
Updated by byroot (Jean Boussier) over 2 years ago
- Backport changed from 2.6: DONTNEED, 2.7: UNKNOWN, 3.0: UNKNOWN, 3.1: UNKNOWN to 2.6: DONTNEED, 2.7: REQUIRED, 3.0: REQUIRED, 3.1: REQUIRED
Updated by byroot (Jean Boussier) over 2 years ago
- Backport changed from 2.6: DONTNEED, 2.7: REQUIRED, 3.0: REQUIRED, 3.1: REQUIRED to 2.6: WONTFIX, 2.7: REQUIRED, 3.0: REQUIRED, 3.1: REQUIRED
Updated by thomthom (Thomas Thomassen) about 2 years ago
Is this targeted to be ported to Ruby 3.1? I see it's not included in 3.1.2 nor in the ruby_3_1 branch.
Updated by byroot (Jean Boussier) about 2 years ago
Is this targeted to be ported to Ruby 3.1?
Yes.
Updated by nagachika (Tomoyuki Chikanaga) about 2 years ago
- Backport changed from 2.6: WONTFIX, 2.7: REQUIRED, 3.0: REQUIRED, 3.1: REQUIRED to 2.6: WONTFIX, 2.7: REQUIRED, 3.0: REQUIRED, 3.1: DONE
ruby_3_1 012015d762d000966a33fcea5f3069decd9d4007 merged revision(s) b8f0dc59d52266d9fbfc039e2f4b0f727c62baa0.