Skip to content

Commit fbcd4f8

Browse files
authored
Merge pull request #9625 from ruby/claude/silly-joliot-7993f2
Implement a make jobserver (continuation of #9210)
2 parents dbd537e + a9818a7 commit fbcd4f8

5 files changed

Lines changed: 198 additions & 9 deletions

File tree

bundler/lib/bundler/installer/parallel_installer.rb

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,31 @@ def failed_specs
110110
end
111111

112112
def install_with_worker
113-
installed_specs = {}
114-
enqueue_specs(installed_specs)
113+
with_jobserver do
114+
installed_specs = {}
115+
enqueue_specs(installed_specs)
115116

116-
process_specs(installed_specs) until finished_installing?
117+
process_specs(installed_specs) until finished_installing?
118+
end
119+
end
120+
121+
def with_jobserver
122+
r, w = IO.pipe
123+
r.close_on_exec = false
124+
w.close_on_exec = false
125+
w.write("*" * @size)
126+
127+
old_makeflags = ENV["MAKEFLAGS"]
128+
ENV["MAKEFLAGS"] = [old_makeflags, "--jobserver-auth=#{r.fileno},#{w.fileno}"].compact.join(" ")
129+
130+
yield
131+
ensure
132+
# Restore MAKEFLAGS before closing the pipe so a close failure can't
133+
# leave the process with descriptors that point at a closed pipe.
134+
old_makeflags ? ENV["MAKEFLAGS"] = old_makeflags : ENV.delete("MAKEFLAGS")
135+
136+
r&.close
137+
w&.close
117138
end
118139

119140
def install_serially

bundler/lib/bundler/rubygems_gem_installer.rb

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
module Bundler
66
class RubyGemsGemInstaller < Gem::Installer
7+
# Cap how many jobserver slots a single gem's `make` may grab so that one
8+
# gem with many recipes doesn't starve the others sharing the pool. Beyond
9+
# a handful of jobs the extra parallelism rarely pays off in practice.
10+
MAX_JOBS_PER_GEM = 3
11+
712
def check_executable_overwrite(filename)
813
# Bundler needs to install gems regardless of binstub overwriting
914
end
@@ -124,10 +129,18 @@ def generate_bin_script(filename, bindir)
124129
end
125130

126131
def build_jobs
127-
Bundler.settings[:jobs] || super
132+
@jobserver_read_io&.read_nonblock(MAX_JOBS_PER_GEM, @jobserver_tokens)
133+
acquired_jobs = @jobserver_tokens.empty? ? nil : @jobserver_tokens.size
134+
135+
acquired_jobs || Bundler.settings[:jobs] || super
136+
rescue IO::WaitReadable, EOFError
137+
1
128138
end
129139

130140
def build_extensions
141+
@jobserver_tokens = +""
142+
@jobserver_read_io, @jobserver_write_io = connect_to_jobserver
143+
131144
extension_cache_path = options[:bundler_extension_cache_path]
132145
extension_dir = spec.extension_dir
133146
unless extension_cache_path && extension_dir
@@ -151,6 +164,11 @@ def build_extensions
151164
FileUtils.cp_r extension_dir, extension_cache_path
152165
end
153166
end
167+
ensure
168+
unless @jobserver_tokens.empty?
169+
@jobserver_write_io.write(@jobserver_tokens)
170+
@jobserver_write_io.flush
171+
end
154172
end
155173

156174
def spec
@@ -167,6 +185,21 @@ def gem_checksum
167185

168186
private
169187

188+
def connect_to_jobserver
189+
return unless ENV["MAKEFLAGS"]
190+
# We append our own --jobserver-auth, so read the last one. Otherwise a
191+
# parent jobserver's descriptors (e.g. `bundle install` run under
192+
# `make -j`) would be picked up instead of the pool ParallelInstaller created.
193+
read_fd, write_fd = ENV["MAKEFLAGS"].scan(/--jobserver-auth=(\d+),(\d+)/).last
194+
195+
return unless read_fd && write_fd
196+
197+
# Pass explicit modes. On POSIX, IO.new detects the descriptor's access
198+
# mode, but on Windows it can't, so the write end would default to read
199+
# mode and raise "IOError: not opened for writing" when releasing slots.
200+
[IO.new(read_fd.to_i, "r", autoclose: false), IO.new(write_fd.to_i, "w", autoclose: false)]
201+
end
202+
170203
def prepare_extension_build(extension_dir)
171204
SharedHelpers.filesystem_access(extension_dir, :create) do
172205
FileUtils.mkdir_p extension_dir

lib/rubygems/ext/builder.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ def self.make(dest_path, results, make_dir = Dir.pwd, sitedir = nil, targets = [
4141
# nmake doesn't support parallel build
4242
unless is_nmake
4343
have_make_arguments = make_program.size > 1
44+
n_jobs ||= 0
4445

45-
if !have_make_arguments && !ENV["MAKEFLAGS"] && n_jobs
46+
if !have_make_arguments && n_jobs > 1 && !ENV["MAKEFLAGS"]&.match(/-j\d*(\s|\Z)/)
4647
make_program << "-j#{n_jobs}"
4748
end
4849
end

spec/bundler/installer/parallel_installer_spec.rb

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,138 @@
7676
parallel_installer.call
7777
end
7878
end
79+
80+
describe "connect to make jobserver" do
81+
before do
82+
unless Gem::Installer.private_method_defined?(:build_jobs)
83+
skip "This example is runnable when RubyGems::Installer implements `build_jobs`"
84+
end
85+
86+
require "support/artifice/compact_index"
87+
88+
@previous_client = Gem::Request::ConnectionPools.client
89+
Gem::Request::ConnectionPools.client = Gem::Net::HTTP
90+
Gem::RemoteFetcher.fetcher.close_all
91+
92+
build_repo2 do
93+
build_gem "one", &:add_c_extension
94+
build_gem "two", &:add_c_extension
95+
end
96+
97+
gemfile <<~G
98+
source "https://gem.repo2"
99+
100+
gem "one"
101+
gem "two"
102+
G
103+
lockfile <<~L
104+
GEM
105+
remote: https://gem.repo2/
106+
specs:
107+
one (1.0)
108+
two (1.0)
109+
110+
DEPENDENCIES
111+
one
112+
two
113+
L
114+
115+
@old_ui = Bundler.ui
116+
Bundler.ui = Bundler::UI::Silent.new
117+
end
118+
119+
after do
120+
Bundler.ui = @old_ui
121+
Gem::Request::ConnectionPools.client = @previous_client
122+
Artifice.deactivate
123+
end
124+
125+
let(:definition) do
126+
allow(Bundler).to receive(:root) { bundled_app }
127+
128+
definition = Bundler::Definition.build(bundled_app.join("Gemfile"), bundled_app.join("Gemfile.lock"), false)
129+
definition.tap(&:setup_domain!)
130+
end
131+
let(:installer) { Bundler::Installer.new(bundled_app, definition) }
132+
let(:gem_one) { definition.specs.find {|spec| spec.name == "one" } }
133+
let(:gem_two) { definition.specs.find {|spec| spec.name == "two" } }
134+
135+
it "takes all available slots" do
136+
redefine_build_jobs do
137+
Bundler::ParallelInstaller.call(installer, definition.specs, 5, false, true)
138+
end
139+
140+
# Take 3 slots out of the 5 available.
141+
expect(File.read(File.join(gem_one.extension_dir, "gem_make.out"))).to include("make -j3")
142+
# Take the remaining 2 slots.
143+
expect(File.read(File.join(gem_two.extension_dir, "gem_make.out"))).to include("make -j2")
144+
end
145+
146+
it "fallback to non parallel when no slots are available" do
147+
redefine_build_jobs do
148+
Bundler::ParallelInstaller.call(installer, definition.specs, 3, false, true)
149+
end
150+
151+
# Take 3 slots out of the 3 available.
152+
expect(File.read(File.join(gem_one.extension_dir, "gem_make.out"))).to include("make -j3")
153+
# Fallback to one slot (non parallel).
154+
expect(File.read(File.join(gem_two.extension_dir, "gem_make.out"))).to_not include("make -j")
155+
end
156+
157+
it "uses one jobs when installing serially" do
158+
Bundler.settings.temporary(jobs: 1) do
159+
Bundler::ParallelInstaller.call(installer, definition.specs, 1, false, true)
160+
end
161+
162+
expect(File.read(File.join(gem_one.extension_dir, "gem_make.out"))).to_not include("make -j")
163+
expect(File.read(File.join(gem_two.extension_dir, "gem_make.out"))).to_not include("make -j")
164+
end
165+
166+
it "release the job slots" do
167+
build_repo2 do
168+
build_gem "one", &:add_c_extension
169+
build_gem "two" do |spec|
170+
spec.add_c_extension
171+
spec.add_dependency(:one) # ParallelInstaller will wait for `one` to be fully installed.
172+
end
173+
end
174+
175+
Bundler::ParallelInstaller.call(installer, definition.specs, 3, false, true)
176+
177+
# Take 3 slots out of the 3 available.
178+
expect(File.read(File.join(gem_one.extension_dir, "gem_make.out"))).to include("make -j3")
179+
# Take 3 slots that were released.
180+
expect(File.read(File.join(gem_two.extension_dir, "gem_make.out"))).to include("make -j3")
181+
end
182+
183+
def redefine_build_jobs
184+
old_method = Bundler::RubyGemsGemInstaller.instance_method(:build_jobs)
185+
Bundler::RubyGemsGemInstaller.remove_method(:build_jobs)
186+
187+
# Rendezvous so that "one" grabs its slots first and keeps holding them
188+
# until "two" has grabbed the rest. Blocking on a queue avoids the
189+
# busy-wait and makes the ordering deterministic.
190+
one_acquired = Thread::Queue.new
191+
two_acquired = Thread::Queue.new
192+
193+
Bundler::RubyGemsGemInstaller.define_method(:build_jobs) do
194+
if spec.name == "one"
195+
value = old_method.bind(self).call
196+
one_acquired << true
197+
two_acquired.pop
198+
elsif spec.name == "two"
199+
one_acquired.pop
200+
value = old_method.bind(self).call
201+
two_acquired << true
202+
end
203+
204+
value
205+
end
206+
207+
yield
208+
ensure
209+
Bundler::RubyGemsGemInstaller.remove_method(:build_jobs)
210+
Bundler::RubyGemsGemInstaller.define_method(:build_jobs, old_method)
211+
end
212+
end
79213
end

spec/commands/install_spec.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,7 +1389,7 @@ def run
13891389
expect(gem_make_out).not_to include("make -j8")
13901390
end
13911391

1392-
it "pass down the BUNDLE_JOBS to RubyGems when running the compilation of an extension" do
1392+
it "uses 3 slots from the available pool when running the compilation of an extension" do
13931393
ENV.delete("MAKEFLAGS")
13941394

13951395
install_gemfile(<<~G, env: { "BUNDLE_JOBS" => "8" })
@@ -1399,10 +1399,10 @@ def run
13991399

14001400
gem_make_out = File.read(File.join(@gemspec.extension_dir, "gem_make.out"))
14011401

1402-
expect(gem_make_out).to include("make -j8")
1402+
expect(gem_make_out).to include("make -j3")
14031403
end
14041404

1405-
it "uses nprocessors by default" do
1405+
it "consumes 3 slots from the pool when BUNDLE_JOBS isn't set" do
14061406
ENV.delete("MAKEFLAGS")
14071407

14081408
install_gemfile(<<~G)
@@ -1412,7 +1412,7 @@ def run
14121412

14131413
gem_make_out = File.read(File.join(@gemspec.extension_dir, "gem_make.out"))
14141414

1415-
expect(gem_make_out).to include("make -j#{Etc.nprocessors + 1}")
1415+
expect(gem_make_out).to include("make -j3")
14161416
end
14171417
end
14181418

0 commit comments

Comments
 (0)