binpacker 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/binpacker/cli.rb +85 -47
- data/lib/binpacker/orchestrator.rb +12 -2
- data/lib/binpacker/report.rb +33 -3
- data/lib/binpacker/shard.rb +62 -0
- data/lib/binpacker/shard_check.rb +127 -0
- data/lib/binpacker/version.rb +1 -1
- data/lib/binpacker.rb +16 -14
- data/sig/binpacker/orchestrator.rbs +4 -1
- data/sig/binpacker/report.rbs +2 -1
- data/sig/binpacker/scheduler.rbs +11 -0
- data/sig/binpacker/shard.rbs +13 -0
- data/sig/binpacker/shard_check.rbs +29 -0
- data/sig/binpacker/worker_queue.rbs +9 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae35133b41b6a8423c8caa45b91a4153498099d2f04c790aeada3fb1e4e0d52a
|
|
4
|
+
data.tar.gz: 5e537cf525779a2783db551b1bf86f7525ce34558f16ce8c2eab253712454b8e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab3dd9a1c6993908e7e646c697d649745d029f15c6a77981ec8b2dd3735afebf92f6194a97e60e2923acc5500fa475e2f7118673cc6c18e2c49ba0a4aa7a7dc1
|
|
7
|
+
data.tar.gz: ad30734e5dbadb596dd753f90ff1163bbc0a12fa60f8d080baad357f9ed2be3ac8a9667d2ed8a52dab2ced6e3e76d2931a1368613976f0a4e2a5af3fd1c4a8da
|
data/lib/binpacker/cli.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require
|
|
4
|
+
require 'optparse'
|
|
5
5
|
|
|
6
6
|
module Binpacker
|
|
7
7
|
class CLI
|
|
@@ -21,27 +21,32 @@ module Binpacker
|
|
|
21
21
|
@skill_path = false
|
|
22
22
|
@skill_describe = false
|
|
23
23
|
@skill_name = nil
|
|
24
|
+
# A CI matrix sets env vars far more easily than it rewrites the command a Makefile target runs, so
|
|
25
|
+
# BINPACKER_SHARD is a first-class way in; an explicit --shard still wins over it.
|
|
26
|
+
@shard_spec = ENV.fetch('BINPACKER_SHARD', nil)
|
|
24
27
|
parse!
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
def run
|
|
28
31
|
case @command
|
|
29
|
-
when
|
|
32
|
+
when 'calibrate'
|
|
30
33
|
cmd_calibrate
|
|
31
|
-
when
|
|
34
|
+
when 'run'
|
|
32
35
|
cmd_run
|
|
33
|
-
when
|
|
36
|
+
when 'shards-check'
|
|
37
|
+
cmd_shards_check
|
|
38
|
+
when 'init'
|
|
34
39
|
cmd_init
|
|
35
|
-
when
|
|
40
|
+
when 'skill'
|
|
36
41
|
cmd_skill
|
|
37
|
-
when
|
|
42
|
+
when 'describe'
|
|
38
43
|
cmd_describe
|
|
39
|
-
when
|
|
44
|
+
when '--version', '-v'
|
|
40
45
|
puts "binpacker #{Binpacker::VERSION}"
|
|
41
|
-
when
|
|
46
|
+
when '--help', '-h', nil
|
|
42
47
|
print_help
|
|
43
48
|
else
|
|
44
|
-
|
|
49
|
+
warn "unknown command: #{@command}"
|
|
45
50
|
print_help
|
|
46
51
|
exit 1
|
|
47
52
|
end
|
|
@@ -51,47 +56,51 @@ module Binpacker
|
|
|
51
56
|
|
|
52
57
|
def parse!
|
|
53
58
|
parser = OptionParser.new do |opts|
|
|
54
|
-
opts.banner =
|
|
59
|
+
opts.banner = 'Usage: binpacker <command> [options]'
|
|
55
60
|
|
|
56
|
-
opts.on(
|
|
61
|
+
opts.on('--profile PROFILE', 'Profile name from binpacker.yml') do |v|
|
|
57
62
|
@profile = v
|
|
58
63
|
end
|
|
59
64
|
|
|
60
|
-
opts.on(
|
|
61
|
-
@command ||=
|
|
65
|
+
opts.on('--version', 'Show version') do
|
|
66
|
+
@command ||= '--version'
|
|
62
67
|
end
|
|
63
68
|
|
|
64
|
-
opts.on(
|
|
65
|
-
@command ||=
|
|
69
|
+
opts.on('--help', 'Show help') do
|
|
70
|
+
@command ||= '--help'
|
|
66
71
|
end
|
|
67
72
|
|
|
68
|
-
opts.on(
|
|
73
|
+
opts.on('--quiet', 'Suppress worker output') do
|
|
69
74
|
@quiet = true
|
|
70
75
|
end
|
|
71
76
|
|
|
72
|
-
opts.on(
|
|
77
|
+
opts.on('--incremental', 'Calibrate only tests without timing data') do
|
|
73
78
|
@incremental = true
|
|
74
79
|
end
|
|
75
80
|
|
|
76
|
-
opts.on(
|
|
81
|
+
opts.on('--report PATH', '(run) Write a JSON run report to PATH') do |v|
|
|
77
82
|
@report_path = v
|
|
78
83
|
end
|
|
79
84
|
|
|
80
|
-
opts.on(
|
|
85
|
+
opts.on('--shard K/N', '(run) Run only shard K of N (1-based)') do |v|
|
|
86
|
+
@shard_spec = v
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
opts.on('--list', '(skill) List bundled skills') do
|
|
81
90
|
@skill_list = true
|
|
82
91
|
end
|
|
83
92
|
|
|
84
|
-
opts.on(
|
|
93
|
+
opts.on('--path', '(skill) Print the SKILL.md path for <name>') do
|
|
85
94
|
@skill_path = true
|
|
86
95
|
end
|
|
87
96
|
|
|
88
|
-
opts.on(
|
|
97
|
+
opts.on('--describe', '(skill) Report project state and next skill') do
|
|
89
98
|
@skill_describe = true
|
|
90
99
|
end
|
|
91
100
|
end
|
|
92
101
|
|
|
93
102
|
# Extract passthrough arguments after "--"
|
|
94
|
-
if (split_idx = @args.index(
|
|
103
|
+
if (split_idx = @args.index('--'))
|
|
95
104
|
@passthrough = @args[(split_idx + 1)..] || []
|
|
96
105
|
@args = @args[0...split_idx]
|
|
97
106
|
end
|
|
@@ -100,11 +109,14 @@ module Binpacker
|
|
|
100
109
|
# A positional command wins; otherwise keep any command an option set
|
|
101
110
|
# (e.g. --version), so `binpacker --version` isn't clobbered to nil.
|
|
102
111
|
@command = remaining.shift || @command
|
|
103
|
-
@skill_name = remaining.
|
|
112
|
+
@skill_name = remaining.first
|
|
113
|
+
# `shards-check` takes a list of run-report paths, so the positionals stay available in full rather
|
|
114
|
+
# than being consumed into the single-name slot `skill` uses.
|
|
115
|
+
@shard_reports = remaining
|
|
104
116
|
end
|
|
105
117
|
|
|
106
118
|
def cmd_init
|
|
107
|
-
config_path = Pathname.pwd.join(
|
|
119
|
+
config_path = Pathname.pwd.join('binpacker.yml')
|
|
108
120
|
if config_path.exist?
|
|
109
121
|
puts "binpacker.yml already exists at #{config_path}"
|
|
110
122
|
exit 1
|
|
@@ -112,11 +124,11 @@ module Binpacker
|
|
|
112
124
|
|
|
113
125
|
framework = detect_framework
|
|
114
126
|
unless ProjectState::SUPPORTED_FRAMEWORKS.include?(framework)
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
warn "Detected #{framework} — binpacker supports rspec and minitest only."
|
|
128
|
+
warn 'No binpacker.yml was created.'
|
|
117
129
|
exit 1
|
|
118
130
|
end
|
|
119
|
-
pattern = framework ==
|
|
131
|
+
pattern = framework == 'minitest' ? 'test/**/*_test.rb' : 'spec/**/*_spec.rb'
|
|
120
132
|
runner = framework
|
|
121
133
|
|
|
122
134
|
yaml = <<~YAML
|
|
@@ -137,15 +149,15 @@ module Binpacker
|
|
|
137
149
|
config_path.write(yaml)
|
|
138
150
|
puts "Created #{config_path}"
|
|
139
151
|
puts "Detected test framework: #{framework}"
|
|
140
|
-
puts
|
|
141
|
-
puts
|
|
142
|
-
puts
|
|
143
|
-
puts
|
|
152
|
+
puts ''
|
|
153
|
+
puts 'Next steps:'
|
|
154
|
+
puts ' 1. binpacker calibrate (seed timing data)'
|
|
155
|
+
puts ' 2. binpacker run (run in parallel)'
|
|
144
156
|
end
|
|
145
157
|
|
|
146
158
|
def cmd_calibrate
|
|
147
159
|
config = Config.new(profile: @profile)
|
|
148
|
-
discovery_klass = config.test_runner ==
|
|
160
|
+
discovery_klass = config.test_runner == 'rspec' ? RSpecDiscovery : MinitestDiscovery
|
|
149
161
|
tests = discovery_klass.new(config).enumerate
|
|
150
162
|
|
|
151
163
|
cal = Calibration.new(config)
|
|
@@ -175,7 +187,7 @@ module Binpacker
|
|
|
175
187
|
if path
|
|
176
188
|
puts path
|
|
177
189
|
else
|
|
178
|
-
|
|
190
|
+
warn "unknown skill: #{@skill_name}"
|
|
179
191
|
exit 1
|
|
180
192
|
end
|
|
181
193
|
elsif @skill_name && !@skill_list
|
|
@@ -187,12 +199,12 @@ module Binpacker
|
|
|
187
199
|
|
|
188
200
|
def cmd_describe
|
|
189
201
|
state = ProjectState.new
|
|
190
|
-
puts
|
|
202
|
+
puts 'binpacker project state:'
|
|
191
203
|
puts " config (binpacker.yml): #{state.config_present? ? 'present' : 'missing'}"
|
|
192
204
|
puts " timing data: #{state.timing_present? ? 'present' : 'missing'}"
|
|
193
205
|
puts " test framework: #{state.framework || 'not detected'}"
|
|
194
206
|
puts " CI wired for binpacker: #{state.ci_wired? ? 'yes' : 'no'}"
|
|
195
|
-
puts
|
|
207
|
+
puts ''
|
|
196
208
|
|
|
197
209
|
unless state.supported_framework?
|
|
198
210
|
puts "binpacker supports rspec and minitest; #{state.framework} is not supported yet."
|
|
@@ -208,45 +220,52 @@ module Binpacker
|
|
|
208
220
|
def list_skills
|
|
209
221
|
skills = Skills.list
|
|
210
222
|
if skills.empty?
|
|
211
|
-
puts
|
|
223
|
+
puts 'No bundled skills found.'
|
|
212
224
|
return
|
|
213
225
|
end
|
|
214
|
-
puts
|
|
226
|
+
puts 'Bundled skills:'
|
|
215
227
|
skills.each { |s| puts " #{s[:name]}" }
|
|
216
|
-
puts
|
|
228
|
+
puts ''
|
|
217
229
|
puts "Print a skill's instructions with `binpacker skill <name>`."
|
|
218
230
|
end
|
|
219
231
|
|
|
220
232
|
def print_skill(name)
|
|
221
233
|
unless Skills.exist?(name)
|
|
222
|
-
|
|
223
|
-
|
|
234
|
+
warn "unknown skill: #{name}"
|
|
235
|
+
warn 'Run `binpacker skill` to list bundled skills.'
|
|
224
236
|
exit 1
|
|
225
237
|
end
|
|
226
238
|
|
|
227
239
|
puts "# Skill: #{name}"
|
|
228
240
|
puts "# Source: #{Skills.path(name)}"
|
|
229
|
-
puts
|
|
241
|
+
puts ''
|
|
230
242
|
puts Skills.body(name)
|
|
231
243
|
end
|
|
232
244
|
|
|
233
245
|
def cmd_run
|
|
234
246
|
config = Config.new(profile: @profile)
|
|
235
247
|
report_path = @report_path || config.report_file
|
|
248
|
+
shard = Shard.parse(@shard_spec)
|
|
236
249
|
orchestrator = Orchestrator.new(
|
|
237
|
-
config, passthrough: @passthrough, quiet: @quiet, report_path: report_path
|
|
250
|
+
config, passthrough: @passthrough, quiet: @quiet, report_path: report_path, shard: shard
|
|
238
251
|
)
|
|
239
252
|
|
|
240
|
-
|
|
253
|
+
start_line = "binpacker starting (#{config.worker_count} workers, profile: #{config.profile}"
|
|
254
|
+
start_line += ", shard: #{shard}" if shard
|
|
255
|
+
puts "#{start_line})"
|
|
241
256
|
result = orchestrator.run
|
|
242
257
|
puts "Run report written to #{report_path}" if report_path
|
|
243
258
|
unit = test_unit_label(config)
|
|
259
|
+
# Named in the summary because a sharded run's totals are its slice's, not the suite's — a reader
|
|
260
|
+
# comparing "10203 examples" with "3401 examples" needs to see which of the two this was.
|
|
261
|
+
scope = shard ? " (shard #{shard})" : ''
|
|
244
262
|
|
|
245
263
|
if result[:passed]
|
|
246
|
-
puts "All #{result[:total]} #{pluralize(result[:total], unit)} passed across
|
|
264
|
+
puts "All #{result[:total]} #{pluralize(result[:total], unit)} passed across " \
|
|
265
|
+
"#{config.worker_count} workers#{scope}."
|
|
247
266
|
exit 0
|
|
248
267
|
elsif result[:empty_filter]
|
|
249
|
-
puts
|
|
268
|
+
puts 'No tests matched the Minitest filter.'
|
|
250
269
|
exit 1
|
|
251
270
|
else
|
|
252
271
|
failed = result[:total] - result[:passed_count]
|
|
@@ -255,8 +274,23 @@ module Binpacker
|
|
|
255
274
|
end
|
|
256
275
|
end
|
|
257
276
|
|
|
277
|
+
# Run after a sharded matrix, over every shard's `--report`. A shard cannot tell "not mine" from "does
|
|
278
|
+
# not exist", so this is the only place a partition that dropped tests can be caught.
|
|
279
|
+
def cmd_shards_check
|
|
280
|
+
result = ShardCheck.call(@shard_reports)
|
|
281
|
+
|
|
282
|
+
if result.ok?
|
|
283
|
+
puts result.summary
|
|
284
|
+
exit 0
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
warn result.summary
|
|
288
|
+
result.problems.each { |problem| warn " #{problem}" }
|
|
289
|
+
exit 1
|
|
290
|
+
end
|
|
291
|
+
|
|
258
292
|
def test_unit_label(config)
|
|
259
|
-
config.test_runner ==
|
|
293
|
+
config.test_runner == 'rspec' ? 'example' : 'test'
|
|
260
294
|
end
|
|
261
295
|
|
|
262
296
|
def pluralize(count, word)
|
|
@@ -269,6 +303,7 @@ module Binpacker
|
|
|
269
303
|
|
|
270
304
|
Commands:
|
|
271
305
|
run Execute tests across worker processes
|
|
306
|
+
shards-check Verify a sharded matrix's run reports cover the whole suite
|
|
272
307
|
calibrate Run tests serially to generate timing data
|
|
273
308
|
init Create binpacker.yml with auto-detected settings
|
|
274
309
|
describe Report project state and recommend the next skill
|
|
@@ -278,6 +313,7 @@ module Binpacker
|
|
|
278
313
|
--profile NAME Select profile from binpacker.yml
|
|
279
314
|
--incremental (calibrate) Measure only tests without timing data
|
|
280
315
|
--report PATH (run) Write a JSON run report to PATH
|
|
316
|
+
--shard K/N (run) Run only shard K of N (1-based; env: BINPACKER_SHARD)
|
|
281
317
|
--list (skill) List bundled skills
|
|
282
318
|
--path (skill) Print the SKILL.md path for <name>
|
|
283
319
|
--describe (skill) Report project state and next skill
|
|
@@ -287,6 +323,8 @@ module Binpacker
|
|
|
287
323
|
binpacker init
|
|
288
324
|
binpacker run --profile ci
|
|
289
325
|
binpacker run -- --tag ~slow
|
|
326
|
+
binpacker run --shard 2/3 --report shard-2.json
|
|
327
|
+
binpacker shards-check shard-*.json
|
|
290
328
|
binpacker calibrate --incremental
|
|
291
329
|
binpacker describe
|
|
292
330
|
binpacker skill binpacker-setup
|
|
@@ -294,7 +332,7 @@ module Binpacker
|
|
|
294
332
|
end
|
|
295
333
|
|
|
296
334
|
def detect_framework
|
|
297
|
-
ProjectState.new.framework ||
|
|
335
|
+
ProjectState.new.framework || 'rspec'
|
|
298
336
|
end
|
|
299
337
|
end
|
|
300
338
|
end
|
|
@@ -24,20 +24,27 @@ module Binpacker
|
|
|
24
24
|
# worker that gave the best makespan in the PR #12 simulation.
|
|
25
25
|
COLD_START_BATCHES_PER_WORKER = 5
|
|
26
26
|
|
|
27
|
-
def initialize(config, passthrough: [], quiet: false, report_path: nil)
|
|
27
|
+
def initialize(config, passthrough: [], quiet: false, report_path: nil, shard: nil)
|
|
28
28
|
@config = config
|
|
29
29
|
@passthrough = passthrough
|
|
30
30
|
@quiet = quiet
|
|
31
31
|
@report_path = report_path
|
|
32
|
+
@shard = shard
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def run
|
|
35
36
|
tests = discover
|
|
36
37
|
timing = Timing.new(@config.timing_file)
|
|
38
|
+
# Weights are computed over the WHOLE suite before slicing, so every shard cuts against the same
|
|
39
|
+
# numbers and the partition they agree on covers every test exactly once.
|
|
37
40
|
timings = timing.load_with_fallback(tests)
|
|
38
41
|
@timings = timings
|
|
39
42
|
|
|
40
43
|
scheduler = Scheduler.for(@config.scheduler['algorithm'])
|
|
44
|
+
@discovered_count = tests.size
|
|
45
|
+
tests = @shard.select(tests: tests, timings: timings, scheduler: scheduler) if @shard
|
|
46
|
+
@selected_count = tests.size
|
|
47
|
+
|
|
41
48
|
queues = scheduler.partition(
|
|
42
49
|
tests: tests,
|
|
43
50
|
worker_count: @config.worker_count,
|
|
@@ -244,7 +251,10 @@ module Binpacker
|
|
|
244
251
|
predicted_loads: @predicted_loads,
|
|
245
252
|
worker_stats: worker_stats,
|
|
246
253
|
all_timings: all_timings,
|
|
247
|
-
timings: @timings
|
|
254
|
+
timings: @timings,
|
|
255
|
+
shard: @shard,
|
|
256
|
+
discovered: @discovered_count,
|
|
257
|
+
selected: @selected_count
|
|
248
258
|
).write(@report_path)
|
|
249
259
|
end
|
|
250
260
|
|
data/lib/binpacker/report.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'json'
|
|
4
4
|
|
|
5
5
|
module Binpacker
|
|
6
6
|
# Builds the machine-readable Run report: predicted versus actual per-Worker
|
|
@@ -9,13 +9,17 @@ module Binpacker
|
|
|
9
9
|
SCHEMA = 1
|
|
10
10
|
DRIFT_LIMIT = 10
|
|
11
11
|
|
|
12
|
-
def initialize(profile:, algorithm:, predicted_loads:, worker_stats:, all_timings:, timings
|
|
12
|
+
def initialize(profile:, algorithm:, predicted_loads:, worker_stats:, all_timings:, timings:,
|
|
13
|
+
shard: nil, discovered: nil, selected: nil)
|
|
13
14
|
@profile = profile
|
|
14
15
|
@algorithm = algorithm
|
|
15
16
|
@predicted_loads = predicted_loads
|
|
16
17
|
@worker_stats = worker_stats
|
|
17
18
|
@all_timings = all_timings
|
|
18
19
|
@timings = timings
|
|
20
|
+
@shard = shard
|
|
21
|
+
@discovered = discovered
|
|
22
|
+
@selected = selected
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def to_h
|
|
@@ -26,6 +30,7 @@ module Binpacker
|
|
|
26
30
|
schema: SCHEMA,
|
|
27
31
|
profile: @profile,
|
|
28
32
|
algorithm: @algorithm,
|
|
33
|
+
shard: shard_section,
|
|
29
34
|
worker_count: @worker_stats.size,
|
|
30
35
|
predicted_makespan: round(predicted.max || 0.0),
|
|
31
36
|
actual_makespan: round(actual.max || 0.0),
|
|
@@ -44,6 +49,29 @@ module Binpacker
|
|
|
44
49
|
|
|
45
50
|
private
|
|
46
51
|
|
|
52
|
+
# The audit trail for a sharded run, and the reason `discovered` is recorded at all.
|
|
53
|
+
#
|
|
54
|
+
# Shards never talk to each other: each computes the same N-way partition and trusts the others to have
|
|
55
|
+
# computed it identically. They do so only while they agree on the timing data the partition is cut
|
|
56
|
+
# from, which in CI means every shard restoring the same timing cache. A shard that restores a
|
|
57
|
+
# different one — a cache miss where its siblings hit — partitions differently, and the failure is
|
|
58
|
+
# silent: tests land in no shard at all and the build stays green.
|
|
59
|
+
#
|
|
60
|
+
# `discovered` is the whole-suite count before slicing, so it agrees across shards that see the same
|
|
61
|
+
# repository. `selected` is this shard's slice. Summing `selected` over a matrix's reports and
|
|
62
|
+
# comparing to the shared `discovered` turns that silent skip into a failure —
|
|
63
|
+
# `binpacker shards-check` does exactly that.
|
|
64
|
+
def shard_section
|
|
65
|
+
return nil unless @shard
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
index: @shard.index,
|
|
69
|
+
total: @shard.total,
|
|
70
|
+
discovered_tests: @discovered,
|
|
71
|
+
selected_tests: @selected
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
47
75
|
def workers
|
|
48
76
|
@worker_stats.map.with_index do |s, i|
|
|
49
77
|
{
|
|
@@ -84,13 +112,15 @@ module Binpacker
|
|
|
84
112
|
end
|
|
85
113
|
|
|
86
114
|
def normalize_file(file)
|
|
87
|
-
file.to_s.sub(%r{\A\./},
|
|
115
|
+
file.to_s.sub(%r{\A\./}, '')
|
|
88
116
|
end
|
|
89
117
|
|
|
90
118
|
def deviation_pct(loads)
|
|
91
119
|
return 0.0 if loads.empty?
|
|
120
|
+
|
|
92
121
|
mean = loads.sum / loads.size
|
|
93
122
|
return 0.0 unless mean.positive?
|
|
123
|
+
|
|
94
124
|
max_dev = loads.map { |t| (t - mean).abs }.max
|
|
95
125
|
round(max_dev / mean * 100)
|
|
96
126
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binpacker
|
|
4
|
+
# One slice of the suite, for splitting a run across independent machines.
|
|
5
|
+
#
|
|
6
|
+
# Workers divide a suite across the cores of ONE machine and share its wall clock; a shard divides it
|
|
7
|
+
# across machines that have no wall clock in common. So the two compose rather than compete: a CI matrix
|
|
8
|
+
# of N jobs each passing `--shard k/N` runs `worker_count` workers over its own slice, and the suite's
|
|
9
|
+
# wall time becomes roughly the slowest shard rather than the whole.
|
|
10
|
+
#
|
|
11
|
+
# The slice is cut by the SAME weight-balanced partitioner that assigns work to workers, over the same
|
|
12
|
+
# measured timings — shards are just a coarser bin-packing of the same problem, so an equal-count split
|
|
13
|
+
# would balance no better than round-robin. That also makes the cut deterministic: given one timing file,
|
|
14
|
+
# every shard computes the identical N-way partition and takes only its own bin, so no shard needs to
|
|
15
|
+
# know what the others decided, and every test lands in exactly one shard.
|
|
16
|
+
class Shard
|
|
17
|
+
attr_reader :index, :total
|
|
18
|
+
|
|
19
|
+
# Parses the `k/n` form used by `--shard` and BINPACKER_SHARD. `k` is 1-based, so a matrix can pass its
|
|
20
|
+
# own 1-based job number straight through.
|
|
21
|
+
#
|
|
22
|
+
# @param spec [String, nil]
|
|
23
|
+
# @return [Shard, nil] nil when `spec` is nil or empty, i.e. an unsharded run.
|
|
24
|
+
def self.parse(spec)
|
|
25
|
+
return nil if spec.nil? || spec.to_s.strip.empty?
|
|
26
|
+
|
|
27
|
+
match = %r{\A\s*(\d+)\s*/\s*(\d+)\s*\z}.match(spec.to_s)
|
|
28
|
+
raise ConfigError, "invalid shard #{spec.inspect}: expected the form K/N, e.g. 1/3" unless match
|
|
29
|
+
|
|
30
|
+
new(index: Integer(match[1]), total: Integer(match[2]))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(index:, total:)
|
|
34
|
+
raise ConfigError, "shard count must be at least 1, got #{total}" if total < 1
|
|
35
|
+
raise ConfigError, "shard index must be between 1 and #{total}, got #{index}" unless (1..total).cover?(index)
|
|
36
|
+
|
|
37
|
+
@index = index
|
|
38
|
+
@total = total
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_s
|
|
43
|
+
"#{index}/#{total}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Whole-suite runs still construct no Shard, so this is only ever true for an explicit `--shard 1/1`.
|
|
47
|
+
def whole_suite?
|
|
48
|
+
total == 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param tests [Array<Test>] every test discovered, before any slicing
|
|
52
|
+
# @param timings [Hash] predicted weight per test key, as `Timing#load_with_fallback` returns
|
|
53
|
+
# @param scheduler [Scheduler] the same partitioner the run uses for workers
|
|
54
|
+
# @return [Array<Test>] the tests belonging to this shard
|
|
55
|
+
def select(tests:, timings:, scheduler:)
|
|
56
|
+
return tests if whole_suite?
|
|
57
|
+
|
|
58
|
+
bins = scheduler.partition(tests: tests, worker_count: total, timings: timings)
|
|
59
|
+
bins[index - 1].remaining
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Binpacker
|
|
6
|
+
# Fan-in audit for a sharded matrix: did the shards, between them, actually run the whole suite?
|
|
7
|
+
#
|
|
8
|
+
# Shards never coordinate. Each cuts the same N-way partition from the timing data it loaded and takes
|
|
9
|
+
# its own bin, which is correct exactly as long as they all loaded the SAME data. In CI that means every
|
|
10
|
+
# shard job restoring the same timing cache — and the failure mode when one does not is the bad one:
|
|
11
|
+
# it partitions differently, some tests land in no shard, and every job still reports success. Nothing
|
|
12
|
+
# inside a single shard can notice, because a shard cannot tell "not mine" from "does not exist".
|
|
13
|
+
#
|
|
14
|
+
# So the check belongs after the matrix, over the run reports it produced. Point it at every shard's
|
|
15
|
+
# report and it fails unless the reports describe one coherent split of one suite.
|
|
16
|
+
class ShardCheck
|
|
17
|
+
Result = Struct.new(:ok, :problems, :summary, keyword_init: true) do
|
|
18
|
+
def ok? = ok
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.call(paths) = new(paths).call
|
|
22
|
+
|
|
23
|
+
def initialize(paths)
|
|
24
|
+
@paths = Array(paths)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
return failure(['no run reports given']) if @paths.empty?
|
|
29
|
+
|
|
30
|
+
reports, unreadable = load_reports
|
|
31
|
+
return failure(unreadable) unless unreadable.empty?
|
|
32
|
+
|
|
33
|
+
shards = reports.filter_map { |path, data| shard_of(path, data) }
|
|
34
|
+
missing = reports.map(&:first) - shards.map { |s| s[:path] }
|
|
35
|
+
return failure(missing.map { |p| "#{p}: no `shard` section — was it run with --shard?" }) unless missing.empty?
|
|
36
|
+
|
|
37
|
+
problems = check(shards)
|
|
38
|
+
problems.empty? ? success(shards) : failure(problems)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def load_reports
|
|
44
|
+
reports = []
|
|
45
|
+
unreadable = []
|
|
46
|
+
@paths.each do |path|
|
|
47
|
+
reports << [path, JSON.parse(File.read(path))]
|
|
48
|
+
rescue Errno::ENOENT
|
|
49
|
+
unreadable << "#{path}: no such file"
|
|
50
|
+
rescue JSON::ParserError => e
|
|
51
|
+
unreadable << "#{path}: not valid JSON (#{e.message})"
|
|
52
|
+
end
|
|
53
|
+
[reports, unreadable]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def shard_of(path, data)
|
|
57
|
+
section = data['shard']
|
|
58
|
+
return nil unless section.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
path: path,
|
|
62
|
+
index: section['index'],
|
|
63
|
+
total: section['total'],
|
|
64
|
+
discovered: section['discovered_tests'],
|
|
65
|
+
selected: section['selected_tests']
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check(shards)
|
|
70
|
+
problems = []
|
|
71
|
+
problems.concat(agreement_problems(shards, :total, 'shard count'))
|
|
72
|
+
problems.concat(agreement_problems(shards, :discovered, 'discovered test count'))
|
|
73
|
+
problems.concat(completeness_problems(shards))
|
|
74
|
+
problems.concat(coverage_problems(shards))
|
|
75
|
+
problems
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Every report must describe the same matrix. Disagreement here means the reports were not produced by
|
|
79
|
+
# one run, and no coverage conclusion drawn from them would mean anything.
|
|
80
|
+
def agreement_problems(shards, field, label)
|
|
81
|
+
values = shards.map { |s| s[field] }.uniq
|
|
82
|
+
return [] if values.size <= 1
|
|
83
|
+
|
|
84
|
+
["shards disagree on #{label}: #{values.sort_by(&:to_s).inspect}"]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A matrix that lost a job silently drops that job's slice, which looks exactly like a smaller suite.
|
|
88
|
+
def completeness_problems(shards)
|
|
89
|
+
total = shards.first[:total]
|
|
90
|
+
return [] if total.nil?
|
|
91
|
+
|
|
92
|
+
seen = shards.map { |s| s[:index] }
|
|
93
|
+
duplicates = seen.tally.select { |_, n| n > 1 }.keys.sort
|
|
94
|
+
problems = []
|
|
95
|
+
problems << "shard #{duplicates.join(', ')} reported more than once" unless duplicates.empty?
|
|
96
|
+
|
|
97
|
+
absent = (1..total).to_a - seen
|
|
98
|
+
problems << "no report for shard #{absent.join(', ')} of #{total}" unless absent.empty?
|
|
99
|
+
problems
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The check this class exists for: the slices must add up to the suite.
|
|
103
|
+
def coverage_problems(shards)
|
|
104
|
+
discovered = shards.first[:discovered]
|
|
105
|
+
return [] if discovered.nil?
|
|
106
|
+
|
|
107
|
+
selected = shards.sum { |s| s[:selected].to_i }
|
|
108
|
+
return [] if selected == discovered
|
|
109
|
+
|
|
110
|
+
verb = selected < discovered ? 'ran no shard' : 'ran in more than one shard'
|
|
111
|
+
["shards cover #{selected} of #{discovered} tests — #{(discovered - selected).abs} #{verb}. " \
|
|
112
|
+
'The shards partitioned different timing data; make every shard load the same timing file.']
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def success(shards)
|
|
116
|
+
Result.new(
|
|
117
|
+
ok: true,
|
|
118
|
+
problems: [],
|
|
119
|
+
summary: "#{shards.size} shards cover all #{shards.first[:discovered]} tests"
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def failure(problems)
|
|
124
|
+
Result.new(ok: false, problems: problems, summary: 'shard coverage check failed')
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/binpacker/version.rb
CHANGED
data/lib/binpacker.rb
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative
|
|
4
|
-
require_relative
|
|
5
|
-
require_relative
|
|
6
|
-
require_relative
|
|
7
|
-
require_relative
|
|
8
|
-
require_relative
|
|
9
|
-
require_relative
|
|
10
|
-
require_relative
|
|
11
|
-
require_relative
|
|
12
|
-
require_relative
|
|
13
|
-
require_relative
|
|
14
|
-
require_relative
|
|
15
|
-
require_relative
|
|
16
|
-
require_relative
|
|
3
|
+
require_relative 'binpacker/version'
|
|
4
|
+
require_relative 'binpacker/config'
|
|
5
|
+
require_relative 'binpacker/timing'
|
|
6
|
+
require_relative 'binpacker/test_discovery'
|
|
7
|
+
require_relative 'binpacker/worker_queue'
|
|
8
|
+
require_relative 'binpacker/scheduler'
|
|
9
|
+
require_relative 'binpacker/shard'
|
|
10
|
+
require_relative 'binpacker/shard_check'
|
|
11
|
+
require_relative 'binpacker/worker'
|
|
12
|
+
require_relative 'binpacker/test_runner'
|
|
13
|
+
require_relative 'binpacker/calibration'
|
|
14
|
+
require_relative 'binpacker/report'
|
|
15
|
+
require_relative 'binpacker/skills'
|
|
16
|
+
require_relative 'binpacker/project_state'
|
|
17
|
+
require_relative 'binpacker/orchestrator'
|
|
18
|
+
require_relative 'binpacker/progress'
|
|
17
19
|
|
|
18
20
|
module Binpacker
|
|
19
21
|
Error = Class.new(StandardError)
|
|
@@ -7,10 +7,13 @@ module Binpacker
|
|
|
7
7
|
@passthrough: untyped
|
|
8
8
|
@quiet: untyped
|
|
9
9
|
@report_path: untyped
|
|
10
|
+
@shard: Binpacker::Shard?
|
|
11
|
+
@discovered_count: Integer
|
|
12
|
+
@selected_count: Integer
|
|
10
13
|
@timings: Hash[untyped, Float]
|
|
11
14
|
@predicted_loads: Array[Float]
|
|
12
15
|
|
|
13
|
-
def initialize: (untyped, ?passthrough: untyped, ?quiet: untyped, ?report_path: untyped) -> void
|
|
16
|
+
def initialize: (untyped, ?passthrough: untyped, ?quiet: untyped, ?report_path: untyped, ?shard: Binpacker::Shard?) -> void
|
|
14
17
|
def run: () -> { passed: untyped, total: untyped, passed_count: untyped, timings: Array[untyped], empty_filter: untyped }
|
|
15
18
|
|
|
16
19
|
private
|
data/sig/binpacker/report.rbs
CHANGED
|
@@ -3,7 +3,8 @@ module Binpacker
|
|
|
3
3
|
SCHEMA: Integer
|
|
4
4
|
DRIFT_LIMIT: Integer
|
|
5
5
|
|
|
6
|
-
def initialize: (profile: untyped, algorithm: untyped, predicted_loads: untyped, worker_stats: untyped, all_timings: untyped, timings: untyped) -> void
|
|
6
|
+
def initialize: (profile: untyped, algorithm: untyped, predicted_loads: untyped, worker_stats: untyped, all_timings: untyped, timings: untyped, ?shard: Binpacker::Shard?, ?discovered: Integer?, ?selected: Integer?) -> void
|
|
7
|
+
def shard_section: () -> Hash[Symbol, untyped]?
|
|
7
8
|
def to_h: () -> Hash[Symbol, untyped]
|
|
8
9
|
def write: (untyped) -> untyped
|
|
9
10
|
end
|
data/sig/binpacker/scheduler.rbs
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
module Binpacker
|
|
2
2
|
class Scheduler
|
|
3
3
|
def self.for: (untyped) -> (Binpacker::LptScheduler | Binpacker::MultifitScheduler)
|
|
4
|
+
|
|
5
|
+
# Declared on the base so a caller holding a `Scheduler` can partition; the base body raises
|
|
6
|
+
# NotImplementedError and every concrete scheduler overrides it.
|
|
7
|
+
def partition: (tests: Array[untyped], worker_count: Integer, timings: Hash[untyped, Float]) -> Array[Binpacker::WorkerQueue]
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class LptScheduler < Scheduler
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class MultifitScheduler < Scheduler
|
|
14
|
+
ITERATIONS: Integer
|
|
4
15
|
end
|
|
5
16
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Binpacker
|
|
2
|
+
class Shard
|
|
3
|
+
attr_reader index: Integer
|
|
4
|
+
attr_reader total: Integer
|
|
5
|
+
|
|
6
|
+
def self.parse: (String?) -> Binpacker::Shard?
|
|
7
|
+
|
|
8
|
+
def initialize: (index: Integer, total: Integer) -> void
|
|
9
|
+
def to_s: () -> String
|
|
10
|
+
def whole_suite?: () -> bool
|
|
11
|
+
def select: (tests: Array[untyped], timings: Hash[untyped, Float], scheduler: Binpacker::Scheduler) -> Array[untyped]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Binpacker
|
|
2
|
+
class ShardCheck
|
|
3
|
+
class Result
|
|
4
|
+
attr_accessor ok: bool
|
|
5
|
+
attr_accessor problems: Array[String]
|
|
6
|
+
attr_accessor summary: String
|
|
7
|
+
|
|
8
|
+
def ok?: () -> bool
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
@paths: Array[String]
|
|
12
|
+
|
|
13
|
+
def self.call: (Array[String]) -> Binpacker::ShardCheck::Result
|
|
14
|
+
|
|
15
|
+
def initialize: (Array[String]) -> void
|
|
16
|
+
def call: () -> Binpacker::ShardCheck::Result
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def load_reports: () -> [Array[[String, untyped]], Array[String]]
|
|
21
|
+
def shard_of: (String, untyped) -> Hash[Symbol, untyped]?
|
|
22
|
+
def check: (Array[Hash[Symbol, untyped]]) -> Array[String]
|
|
23
|
+
def agreement_problems: (Array[Hash[Symbol, untyped]], Symbol, String) -> Array[String]
|
|
24
|
+
def completeness_problems: (Array[Hash[Symbol, untyped]]) -> Array[String]
|
|
25
|
+
def coverage_problems: (Array[Hash[Symbol, untyped]]) -> Array[String]
|
|
26
|
+
def success: (Array[Hash[Symbol, untyped]]) -> Binpacker::ShardCheck::Result
|
|
27
|
+
def failure: (Array[String]) -> Binpacker::ShardCheck::Result
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
module Binpacker
|
|
2
2
|
class WorkerQueue
|
|
3
|
+
include Enumerable[untyped]
|
|
4
|
+
|
|
5
|
+
attr_reader worker_id: Integer
|
|
6
|
+
|
|
3
7
|
def initialize: (untyped, ?untyped) -> void
|
|
4
8
|
def empty?: () -> (false | true)
|
|
5
9
|
def pop: () -> untyped
|
|
10
|
+
def peek: () -> untyped
|
|
11
|
+
def size: () -> Integer
|
|
12
|
+
def remaining: () -> Array[untyped]
|
|
6
13
|
def total_weight: (Hash[untyped, Float]) -> Float
|
|
14
|
+
def push: (untyped) -> untyped
|
|
15
|
+
def each: () { (untyped) -> void } -> void
|
|
7
16
|
end
|
|
8
17
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: binpacker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- megurine
|
|
@@ -29,6 +29,8 @@ files:
|
|
|
29
29
|
- lib/binpacker/project_state.rb
|
|
30
30
|
- lib/binpacker/report.rb
|
|
31
31
|
- lib/binpacker/scheduler.rb
|
|
32
|
+
- lib/binpacker/shard.rb
|
|
33
|
+
- lib/binpacker/shard_check.rb
|
|
32
34
|
- lib/binpacker/skills.rb
|
|
33
35
|
- lib/binpacker/test_discovery.rb
|
|
34
36
|
- lib/binpacker/test_runner.rb
|
|
@@ -44,6 +46,8 @@ files:
|
|
|
44
46
|
- sig/binpacker/project_state.rbs
|
|
45
47
|
- sig/binpacker/report.rbs
|
|
46
48
|
- sig/binpacker/scheduler.rbs
|
|
49
|
+
- sig/binpacker/shard.rbs
|
|
50
|
+
- sig/binpacker/shard_check.rbs
|
|
47
51
|
- sig/binpacker/skills.rbs
|
|
48
52
|
- sig/binpacker/test_discovery.rbs
|
|
49
53
|
- sig/binpacker/test_runner.rbs
|