Speed up default gem loading by adding files stub line to gemspec#9385
Speed up default gem loading by adding files stub line to gemspec#9385
Conversation
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>
There was a problem hiding this comment.
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 toGem::Specification#to_ruby(size-capped) so file lists can be read without eval. - Extend
Gem::StubSpecificationto parse and expose the stubbed file list and addactivatedelegation 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.
| 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 |
There was a problem hiding this comment.
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.
| 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 |
| unless files.empty? | ||
| files_line = "#{Gem::StubSpecification::FILES_PREFIX}#{files.join "\0"}" | ||
| result << files_line if files_line.bytesize <= 200 | ||
| end |
There was a problem hiding this comment.
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).
| def files | ||
| data.files |
There was a problem hiding this comment.
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).
| 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 |
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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>
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