Skip to content

Speed up default gem loading by adding files stub line to gemspec#9385

Open
hsbt wants to merge 3 commits intomasterfrom
improve-bootstrap-default-gems
Open

Speed up default gem loading by adding files stub line to gemspec#9385
hsbt wants to merge 3 commits intomasterfrom
improve-bootstrap-default-gems

Conversation

@hsbt
Copy link
Member

@hsbt hsbt commented Mar 11, 2026

What was the end-user or developer problem that led to this PR?

Fixes #3799

During Ruby boot, RubyGems loads all default gem specifications by reading and eval'ing each gemspec file to build the path-to-spec mapping used by Kernel#require. This eval overhead is the largest contributor to RubyGems initialization time (~43% of samples in profiling).

What is your fix for the problem, implemented in this PR?

This change adds a "# files:" comment line to gemspec headers, alongside the existing "# stub:" lines for name/version/extensions. When present, StubSpecification can read the file list from the first few lines of the gemspec without eval'ing the full Ruby code.

For default gems with many files (>200 bytes in the files stub line), the line is omitted and the traditional eval path is used as fallback. On a typical Ruby installation, ~73% of default gems (33 of 45) fit within this limit, reducing the eval cost of load_defaults by ~64%.

Make sure the following tasks are checked

During Ruby boot, RubyGems loads all default gem specifications by
reading and eval'ing each gemspec file to build the path-to-spec
mapping used by Kernel#require. This eval overhead is the largest
contributor to RubyGems initialization time (~43% of samples in
profiling).

This change adds a "# files:" comment line to gemspec headers,
alongside the existing "# stub:" lines for name/version/extensions.
When present, StubSpecification can read the file list from the
first few lines of the gemspec without eval'ing the full Ruby code.

For default gems with many files (>200 bytes in the files stub line),
the line is omitted and the traditional eval path is used as fallback.
On a typical Ruby installation, ~73% of default gems (33 of 45) fit
within this limit, reducing the eval cost of load_defaults by ~64%.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 11, 2026 11:21
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR speeds up RubyGems default gem loading by allowing StubSpecification to read a new # files: header from default gemspecs, avoiding full gemspec eval for many default gems during Ruby boot.

Changes:

  • Add a # files: stub header to Gem::Specification#to_ruby (size-capped) so file lists can be read without eval.
  • Extend Gem::StubSpecification to parse and expose the stubbed file list and add activate delegation for stub-registered default specs.
  • Update tests to cover parsing the files stub and ensuring default specs written by the installer include the files stub.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
lib/rubygems/stub_specification.rb Parse # files: header into stub data; expose files and add activate delegation.
lib/rubygems/specification.rb Use stubs in load_defaults when files stub exists; emit # files: header from to_ruby when small enough.
test/rubygems/test_gem_stub_specification.rb Add coverage for reading # files: headers (with/without extensions) and missing files stub sentinel behavior.
test/rubygems/test_gem_installer.rb Assert write_default_spec emits a default gemspec that can be read as a files-stubbed StubSpecification.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +847 to +853
stub = Gem::StubSpecification.default_gemspec_stub(path, base_dir, gems_dir)
if stub.stubbed? && !stub.files.equal?(Gem::StubSpecification::StubLine::NO_FILES)
Gem.register_default_spec(stub)
else
spec = load(path)
Gem.register_default_spec(spec) if spec
end
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

load_defaults now registers Gem::StubSpecification instances into the default-spec path map (via Gem.register_default_spec(stub)). This changes what Gem.find_default_spec returns (it used to be a Gem::Specification), and it also breaks Gem.find_unresolved_default_spec's current logic (loaded_specs[name] != default_spec will remain true after activation because the loaded spec is a different object than the stub). Consider either adjusting those helper methods to handle stubs (e.g., treat any loaded spec with the same name as resolved), or avoid storing stubs in the map where a full spec is required for API compatibility.

Suggested change
stub = Gem::StubSpecification.default_gemspec_stub(path, base_dir, gems_dir)
if stub.stubbed? && !stub.files.equal?(Gem::StubSpecification::StubLine::NO_FILES)
Gem.register_default_spec(stub)
else
spec = load(path)
Gem.register_default_spec(spec) if spec
end
spec = load(path)
Gem.register_default_spec(spec) if spec

Copilot uses AI. Check for mistakes.
Comment on lines +2383 to +2386
unless files.empty?
files_line = "#{Gem::StubSpecification::FILES_PREFIX}#{files.join "\0"}"
result << files_line if files_line.bytesize <= 200
end
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

Gem::Specification#to_ruby now conditionally emits a # files: stub header line. There are existing tests that assert the exact to_ruby output (e.g., in test/rubygems/test_gem_specification.rb around test_to_ruby/test_to_ruby_with_rsa_key/test_to_ruby_fancy) that currently don't include this new header and will fail. Please update those expected strings (and ideally add a case that verifies the >200-byte cutoff omits the # files: line).

Copilot uses AI. Check for mistakes.
Comment on lines +159 to +160
def files
data.files
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

The docstring for Gem::StubSpecification#files says it returns files from the full specification when the stub line isn’t available, but the current implementation always returns data.files, which is StubLine::NO_FILES when the # files: header is missing (i.e., it does not fall back to to_spec.files). Either update the comment to match the sentinel semantics, or implement an explicit fallback (while keeping a separate non-eval accessor for the stubbed list).

Suggested change
def files
data.files
# Files from the stub line only, without falling back to the full spec.
# This accessor does not trigger loading the full specification.
def stubbed_files # :nodoc:
if defined?(@data) && @data.is_a?(StubLine)
@data.files
else
StubLine::NO_FILES
end
end
def files
if defined?(@data) && @data.is_a?(StubLine) &&
@data.files.equal?(StubLine::NO_FILES)
# No files were recorded in the stub, fall back to the full specification
to_spec.files
else
data.files
end

Copilot uses AI. Check for mistakes.
The files stub line feature added in the previous commit causes to_ruby
to output a `# files:` comment line, but four tests were not updated
to expect this new line in their expected output strings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
extensions.empty?
unless files.empty?
files_line = "#{Gem::StubSpecification::FILES_PREFIX}#{files.join "\0"}"
result << files_line if files_line.bytesize <= 200
Copy link
Collaborator

Choose a reason for hiding this comment

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

For default gems with many files [...]

Do you think we should count the number of files instead of the bytesize of the string ? e.g a gem with few files with long paths name vs a gem with many files but short paths name

Copy link
Member Author

Choose a reason for hiding this comment

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

That's right.
At first, I thought it would be bad if the gemspec increased as the line got longer, so I set it to byte length. However, the number of files may have a greater impact on performance across the entire RubyGems.

I will measure them.

The files stub line added to to_ruby output shifted line numbers in
generated gemspecs, causing the hardcoded spec_lines[5] to overwrite
the name attribute instead of the version. Use dynamic index lookup
to find the version line regardless of gemspec header format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

Could boot performance be improved? Caching default gems perhaps?

3 participants