binpacker 0.1.0 → 0.3.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/exe/binpacker-worker +163 -43
- data/lib/binpacker/calibration.rb +20 -8
- data/lib/binpacker/cli.rb +192 -8
- data/lib/binpacker/config.rb +4 -1
- data/lib/binpacker/orchestrator.rb +208 -15
- data/lib/binpacker/progress.rb +117 -0
- data/lib/binpacker/project_state.rb +82 -0
- data/lib/binpacker/report.rb +102 -0
- data/lib/binpacker/scheduler.rb +75 -10
- data/lib/binpacker/skills.rb +36 -0
- data/lib/binpacker/test_discovery.rb +62 -8
- data/lib/binpacker/timing.rb +5 -0
- data/lib/binpacker/version.rb +1 -1
- data/lib/binpacker/worker.rb +59 -4
- data/lib/binpacker.rb +4 -0
- data/sig/binpacker/calibration.rbs +6 -0
- data/sig/binpacker/cli.rbs +7 -0
- data/sig/binpacker/config.rbs +13 -0
- data/sig/binpacker/orchestrator.rbs +6 -0
- data/sig/binpacker/progress.rbs +5 -0
- data/sig/binpacker/project_state.rbs +16 -0
- data/sig/binpacker/report.rbs +10 -0
- data/sig/binpacker/scheduler.rbs +5 -0
- data/sig/binpacker/skills.rbs +10 -0
- data/sig/binpacker/test_discovery.rbs +15 -0
- data/sig/binpacker/test_runner.rbs +13 -0
- data/sig/binpacker/timing.rbs +11 -0
- data/sig/binpacker/worker.rbs +12 -0
- data/sig/binpacker/worker_queue.rbs +6 -0
- data/skills/binpacker-improve/SKILL.md +52 -0
- data/skills/binpacker-setup/SKILL.md +76 -0
- metadata +25 -5
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Binpacker
|
|
4
4
|
class Orchestrator
|
|
5
|
-
|
|
5
|
+
BATCH_SIZE = 10
|
|
6
|
+
|
|
7
|
+
def initialize(config, passthrough: [], quiet: false, report_path: nil)
|
|
6
8
|
@config = config
|
|
7
9
|
@passthrough = passthrough
|
|
10
|
+
@quiet = quiet
|
|
11
|
+
@report_path = report_path
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def run
|
|
11
15
|
tests = discover
|
|
12
|
-
|
|
13
16
|
timing = Timing.new(@config.timing_file)
|
|
14
17
|
timings = timing.load_with_fallback(tests)
|
|
18
|
+
@timings = timings
|
|
15
19
|
|
|
16
20
|
scheduler = Scheduler.for(@config.scheduler["algorithm"])
|
|
17
21
|
queues = scheduler.partition(
|
|
@@ -20,21 +24,53 @@ module Binpacker
|
|
|
20
24
|
timings: timings
|
|
21
25
|
)
|
|
22
26
|
|
|
27
|
+
# Capture predicted per-Worker loads before execution drains the queues.
|
|
28
|
+
@predicted_loads = queues.map { |q| q.total_weight(timings) }
|
|
29
|
+
|
|
23
30
|
runner_class = TestRunner.for(@config.test_runner)
|
|
24
31
|
workers = queues.map.with_index do |queue, idx|
|
|
25
|
-
Worker.new(idx, runner_class, passthrough: @passthrough).tap(&:start)
|
|
32
|
+
Worker.new(idx, runner_class, passthrough: @passthrough, quiet: @quiet).tap(&:start)
|
|
26
33
|
end
|
|
27
34
|
|
|
35
|
+
if @config.scheduler["steal_enabled"]
|
|
36
|
+
run_dynamic(workers, queues, timing, tests)
|
|
37
|
+
else
|
|
38
|
+
run_static(workers, queues, timing, tests)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def discover
|
|
45
|
+
case @config.test_runner
|
|
46
|
+
when "rspec"
|
|
47
|
+
RSpecDiscovery.new(@config).enumerate
|
|
48
|
+
when "minitest"
|
|
49
|
+
MinitestDiscovery.new(@config).enumerate
|
|
50
|
+
else
|
|
51
|
+
raise ConfigError, "unsupported runner: #{@config.test_runner}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def run_static(workers, queues, timing, tests)
|
|
56
|
+
queue_totals = queues.map(&:size)
|
|
57
|
+
progress = ProgressDisplay.new(workers.size)
|
|
58
|
+
|
|
28
59
|
workers.zip(queues).each do |worker, queue|
|
|
29
60
|
worker.send_tests(queue.remaining)
|
|
61
|
+
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file || "")
|
|
30
62
|
end
|
|
63
|
+
progress.refresh
|
|
64
|
+
|
|
65
|
+
workers.each(&:signal_done)
|
|
31
66
|
|
|
32
67
|
all_timings = []
|
|
33
68
|
all_passed = true
|
|
34
69
|
total_examples = 0
|
|
35
70
|
passed_examples = 0
|
|
36
|
-
|
|
37
|
-
workers.
|
|
71
|
+
worker_time = Array.new(workers.size, 0.0)
|
|
72
|
+
worker_examples = Array.new(workers.size, 0)
|
|
73
|
+
worker_passed = Array.new(workers.size, 0)
|
|
38
74
|
|
|
39
75
|
workers.each do |worker|
|
|
40
76
|
worker.collect_results
|
|
@@ -42,6 +78,11 @@ module Binpacker
|
|
|
42
78
|
all_passed &&= worker.success?
|
|
43
79
|
total_examples += worker.example_count
|
|
44
80
|
passed_examples += worker.passed_count
|
|
81
|
+
worker_examples[worker.id] = worker.example_count
|
|
82
|
+
worker_passed[worker.id] = worker.passed_count
|
|
83
|
+
worker_time[worker.id] = worker.timings.sum { |t| t[:time] }
|
|
84
|
+
progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file: "done")
|
|
85
|
+
progress.refresh
|
|
45
86
|
rescue WorkerError => e
|
|
46
87
|
$stderr.puts "worker #{worker.id} error: #{e.message}"
|
|
47
88
|
all_passed = false
|
|
@@ -49,26 +90,178 @@ module Binpacker
|
|
|
49
90
|
worker.cleanup
|
|
50
91
|
end
|
|
51
92
|
|
|
93
|
+
progress.finish
|
|
94
|
+
|
|
95
|
+
worker_stats = workers.map.with_index do |w, i|
|
|
96
|
+
{
|
|
97
|
+
files: queue_totals[i],
|
|
98
|
+
total_time: worker_time[i],
|
|
99
|
+
examples: worker_examples[i],
|
|
100
|
+
passed: worker_passed[i]
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
progress.summary(worker_stats)
|
|
104
|
+
write_report(worker_stats, all_timings)
|
|
105
|
+
|
|
106
|
+
finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run_dynamic(workers, queues, timing, tests)
|
|
110
|
+
all_timings = []
|
|
111
|
+
all_passed = true
|
|
112
|
+
total_examples = 0
|
|
113
|
+
passed_examples = 0
|
|
114
|
+
active = []
|
|
115
|
+
|
|
116
|
+
queue_totals = queues.map(&:size)
|
|
117
|
+
worker_done = Array.new(workers.size, 0)
|
|
118
|
+
batch_sizes = Array.new(workers.size, 0)
|
|
119
|
+
worker_time = Array.new(workers.size, 0.0)
|
|
120
|
+
worker_examples = Array.new(workers.size, 0)
|
|
121
|
+
worker_passed = Array.new(workers.size, 0)
|
|
122
|
+
|
|
123
|
+
progress = ProgressDisplay.new(workers.size)
|
|
124
|
+
|
|
125
|
+
workers.zip(queues).each do |worker, queue|
|
|
126
|
+
batch = drain_batch(queue)
|
|
127
|
+
if batch.empty?
|
|
128
|
+
worker.signal_done
|
|
129
|
+
worker.collect_results
|
|
130
|
+
all_timings.concat(worker.timings)
|
|
131
|
+
all_passed &&= worker.success?
|
|
132
|
+
total_examples += worker.example_count
|
|
133
|
+
passed_examples += worker.passed_count
|
|
134
|
+
worker_examples[worker.id] = worker.example_count
|
|
135
|
+
worker_passed[worker.id] = worker.passed_count
|
|
136
|
+
worker.cleanup
|
|
137
|
+
worker_done[worker.id] = queue_totals[worker.id]
|
|
138
|
+
progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file: "done")
|
|
139
|
+
else
|
|
140
|
+
worker.send_tests(batch)
|
|
141
|
+
worker.batch_done
|
|
142
|
+
active << worker
|
|
143
|
+
batch_sizes[worker.id] = batch.size
|
|
144
|
+
current_file = batch.first&.file || ""
|
|
145
|
+
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: current_file)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
until active.empty?
|
|
150
|
+
ready = active.find { |w| w.wait_for_batch }
|
|
151
|
+
unless ready
|
|
152
|
+
active.reject! { |w| w.status == :crashed || w.status == :error }
|
|
153
|
+
sleep 0.1
|
|
154
|
+
next
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
begin
|
|
158
|
+
all_passed &&= ready.success?
|
|
159
|
+
total_examples += ready.example_count
|
|
160
|
+
passed_examples += ready.passed_count
|
|
161
|
+
|
|
162
|
+
worker_done[ready.id] += batch_sizes[ready.id]
|
|
163
|
+
worker_examples[ready.id] = ready.example_count
|
|
164
|
+
worker_passed[ready.id] = ready.passed_count
|
|
165
|
+
|
|
166
|
+
own_queue = queues[ready.id]
|
|
167
|
+
next_batch = drain_batch(own_queue)
|
|
168
|
+
|
|
169
|
+
if next_batch.empty?
|
|
170
|
+
donor = queues.reject(&:empty?).max_by(&:size)
|
|
171
|
+
next_batch = drain_batch(donor) if donor
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if next_batch.any?
|
|
175
|
+
ready.send_tests(next_batch)
|
|
176
|
+
ready.batch_done
|
|
177
|
+
batch_sizes[ready.id] = next_batch.size
|
|
178
|
+
current_file = next_batch.first&.file || ""
|
|
179
|
+
progress.update(ready.id, done: worker_done[ready.id], total: queue_totals[ready.id], file: current_file)
|
|
180
|
+
progress.refresh
|
|
181
|
+
else
|
|
182
|
+
ready.signal_done
|
|
183
|
+
all_timings.concat(ready.timings)
|
|
184
|
+
active.delete(ready)
|
|
185
|
+
worker_done[ready.id] = queue_totals[ready.id]
|
|
186
|
+
progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file: "done")
|
|
187
|
+
progress.refresh
|
|
188
|
+
end
|
|
189
|
+
rescue WorkerError => e
|
|
190
|
+
$stderr.puts "worker #{ready.id} error: #{e.message}"
|
|
191
|
+
all_passed = false
|
|
192
|
+
active.delete(ready)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
progress.finish
|
|
197
|
+
|
|
198
|
+
worker_stats = workers.map.with_index do |w, i|
|
|
199
|
+
tw = w.timings.sum { |t| t[:time] }
|
|
200
|
+
{
|
|
201
|
+
files: queue_totals[i],
|
|
202
|
+
total_time: tw > 0 ? tw : worker_time[i],
|
|
203
|
+
examples: worker_examples[i],
|
|
204
|
+
passed: worker_passed[i]
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
progress.summary(worker_stats)
|
|
208
|
+
write_report(worker_stats, all_timings)
|
|
209
|
+
|
|
210
|
+
workers.each(&:cleanup)
|
|
211
|
+
finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def write_report(worker_stats, all_timings)
|
|
215
|
+
return unless @report_path
|
|
216
|
+
|
|
217
|
+
Report.new(
|
|
218
|
+
profile: @config.profile,
|
|
219
|
+
algorithm: @config.scheduler["algorithm"],
|
|
220
|
+
predicted_loads: @predicted_loads,
|
|
221
|
+
worker_stats: worker_stats,
|
|
222
|
+
all_timings: all_timings,
|
|
223
|
+
timings: @timings
|
|
224
|
+
).write(@report_path)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def drain_batch(queue)
|
|
228
|
+
return [] if queue.nil? || queue.empty?
|
|
229
|
+
batch = []
|
|
230
|
+
BATCH_SIZE.times do
|
|
231
|
+
test = queue.pop
|
|
232
|
+
break unless test
|
|
233
|
+
batch << test
|
|
234
|
+
end
|
|
235
|
+
batch
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
|
|
52
239
|
timing.append_all(all_timings) unless all_timings.empty?
|
|
240
|
+
empty_filter = minitest_empty_filter?(tests, total_examples)
|
|
241
|
+
all_passed = false if empty_filter
|
|
53
242
|
|
|
54
243
|
{
|
|
55
244
|
passed: all_passed,
|
|
56
245
|
total: total_examples,
|
|
57
246
|
passed_count: passed_examples,
|
|
58
|
-
timings: all_timings
|
|
247
|
+
timings: all_timings,
|
|
248
|
+
empty_filter: empty_filter
|
|
59
249
|
}
|
|
60
250
|
end
|
|
61
251
|
|
|
62
|
-
|
|
252
|
+
def minitest_empty_filter?(tests, total_examples)
|
|
253
|
+
return false unless @config.test_runner == "minitest"
|
|
254
|
+
return false unless tests.any?
|
|
255
|
+
return false unless total_examples.zero?
|
|
63
256
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
257
|
+
minitest_include_filter?
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def minitest_include_filter?
|
|
261
|
+
@passthrough.any? do |arg|
|
|
262
|
+
%w[--name --include -n -i].include?(arg) ||
|
|
263
|
+
arg.start_with?("--name=", "--include=") ||
|
|
264
|
+
(arg.start_with?("-n", "-i") && arg.length > 2)
|
|
72
265
|
end
|
|
73
266
|
end
|
|
74
267
|
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binpacker
|
|
4
|
+
class ProgressDisplay
|
|
5
|
+
CI_INTERVAL = 15 # seconds between CI output lines
|
|
6
|
+
|
|
7
|
+
def initialize(worker_count, tty: $stdout.tty?)
|
|
8
|
+
@worker_count = worker_count
|
|
9
|
+
@tty = tty
|
|
10
|
+
@workers = Array.new(worker_count) { { done: 0, total: 0, file: "", elapsed: 0.0 } }
|
|
11
|
+
@start = Time.now
|
|
12
|
+
@last_ci_output = Time.at(Time.now.to_f - CI_INTERVAL)
|
|
13
|
+
@lines_written = 0
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update(worker_id, done:, total:, file:, elapsed: 0.0)
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
w = @workers[worker_id]
|
|
20
|
+
w[:done] = done
|
|
21
|
+
w[:total] = total
|
|
22
|
+
w[:file] = file
|
|
23
|
+
w[:elapsed] = elapsed
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def refresh
|
|
28
|
+
if @tty
|
|
29
|
+
redraw
|
|
30
|
+
else
|
|
31
|
+
periodic_output
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def finish(worker_stats = [])
|
|
36
|
+
return unless @tty
|
|
37
|
+
redraw
|
|
38
|
+
$stdout.puts
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def summary(worker_stats)
|
|
42
|
+
active = worker_stats.reject { |s| s[:files] == 0 && s[:examples] == 0 }
|
|
43
|
+
return if active.empty?
|
|
44
|
+
|
|
45
|
+
active.each_with_index do |s, i|
|
|
46
|
+
t = format_time(s[:total_time])
|
|
47
|
+
wid = worker_stats.index(s)
|
|
48
|
+
$stdout.puts " Worker #{wid}: #{s[:files]} tests, #{t} | #{s[:examples]} examples, #{s[:passed]} passed"
|
|
49
|
+
end
|
|
50
|
+
total_tests = active.sum { |s| s[:files] }
|
|
51
|
+
total_time = active.sum { |s| s[:total_time] }
|
|
52
|
+
total_examples = active.sum { |s| s[:examples] }
|
|
53
|
+
times = active.map { |s| s[:total_time] }
|
|
54
|
+
mean = total_time / active.size
|
|
55
|
+
max_dev = times.map { |t| (t - mean).abs }.max
|
|
56
|
+
dev_pct = mean > 0 ? (max_dev / mean * 100).round(1) : 0
|
|
57
|
+
|
|
58
|
+
$stdout.puts " ──"
|
|
59
|
+
$stdout.puts " Total: #{total_tests} tests, #{format_time(total_time)} | #{total_examples} examples"
|
|
60
|
+
$stdout.puts " Balance: max deviation #{format_time(max_dev)} (#{dev_pct}%)"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def redraw
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
clear_lines
|
|
68
|
+
@workers.each_with_index do |w, i|
|
|
69
|
+
bar = build_bar(w[:done], w[:total])
|
|
70
|
+
status = w[:total] > 0 && w[:done] >= w[:total] ? "done" : w[:file][-50..] || ""
|
|
71
|
+
$stdout.puts format_line(i, bar, w[:done], w[:total], status, w[:elapsed])
|
|
72
|
+
end
|
|
73
|
+
@lines_written = @worker_count
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def clear_lines
|
|
78
|
+
return if @lines_written == 0
|
|
79
|
+
@lines_written.times do
|
|
80
|
+
$stdout.print "\033[A\033[K"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_bar(done, total)
|
|
85
|
+
return "[----------]" if total == 0
|
|
86
|
+
width = 10
|
|
87
|
+
filled = (done.to_f / total * width).round
|
|
88
|
+
"[#{'█' * filled}#{'░' * (width - filled)}]"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def format_line(idx, bar, done, total, file, elapsed)
|
|
92
|
+
ts = format_time(elapsed)
|
|
93
|
+
"W#{idx} #{bar} #{done.to_s.rjust(3)}/#{total.to_s.ljust(3)} #{file.ljust(50)} #{ts}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def periodic_output
|
|
97
|
+
now = Time.now
|
|
98
|
+
return if now - @last_ci_output < CI_INTERVAL
|
|
99
|
+
@last_ci_output = now
|
|
100
|
+
|
|
101
|
+
parts = @workers.map.with_index do |w, i|
|
|
102
|
+
ratio = w[:total] > 0 ? "#{w[:done]}/#{w[:total]}" : "0/?"
|
|
103
|
+
"W#{i}: #{ratio}"
|
|
104
|
+
end
|
|
105
|
+
elapsed = (now - @start).round(1)
|
|
106
|
+
$stdout.puts "[binpacker #{elapsed}s] #{parts.join(' | ')}"
|
|
107
|
+
$stdout.flush
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def format_time(seconds)
|
|
111
|
+
return " 0.0s" if seconds < 0.001
|
|
112
|
+
m = (seconds / 60).floor
|
|
113
|
+
s = (seconds % 60).round(1)
|
|
114
|
+
m > 0 ? "#{m}m#{s.to_s.rjust(4, '0')}s" : "#{s.to_s.rjust(5)}s"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binpacker
|
|
4
|
+
# Inspects the working directory to report binpacker's setup state and
|
|
5
|
+
# recommend the next skill. Backs `binpacker describe`.
|
|
6
|
+
# See docs/design/agent-workflows.md.
|
|
7
|
+
class ProjectState
|
|
8
|
+
CONFIG_FILE = "binpacker.yml"
|
|
9
|
+
DEFAULT_TIMING_FILE = "binpacker.timings"
|
|
10
|
+
SUPPORTED_FRAMEWORKS = %w[rspec minitest].freeze
|
|
11
|
+
TESTUNIT_HINT = %r{test-unit|test/unit}
|
|
12
|
+
|
|
13
|
+
def config_present?
|
|
14
|
+
File.exist?(CONFIG_FILE)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def timing_present?
|
|
18
|
+
File.exist?(timing_file)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# "rspec", "minitest", "test-unit", or nil. Minitest and test-unit share
|
|
22
|
+
# the *_test.rb convention, so they are told apart by dependency hints.
|
|
23
|
+
def framework
|
|
24
|
+
return minitest_family if minitest_globs?
|
|
25
|
+
return "rspec" if Dir.glob("spec/**/*_spec.rb").any?
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# binpacker ships runners for rspec and minitest only. A detected framework
|
|
30
|
+
# outside that set (e.g. test-unit) is unsupported; nil is merely unknown.
|
|
31
|
+
def supported_framework?
|
|
32
|
+
fw = framework
|
|
33
|
+
fw.nil? || SUPPORTED_FRAMEWORKS.include?(fw)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ci_wired?
|
|
37
|
+
Dir.glob(".github/workflows/*.{yml,yaml}").any? do |wf|
|
|
38
|
+
File.read(wf).include?("binpacker run")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The skill the agent should run next.
|
|
43
|
+
def recommendation
|
|
44
|
+
return "binpacker-setup" unless config_present? && timing_present?
|
|
45
|
+
"binpacker-improve"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{
|
|
50
|
+
config_present: config_present?,
|
|
51
|
+
timing_present: timing_present?,
|
|
52
|
+
framework: framework,
|
|
53
|
+
ci_wired: ci_wired?,
|
|
54
|
+
recommendation: recommendation
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def minitest_globs?
|
|
61
|
+
Dir.glob("test*/**/*_test.rb").any? || Dir.glob("test*/**/test_*.rb").any?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def minitest_family
|
|
65
|
+
testunit_hinted? ? "test-unit" : "minitest"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def testunit_hinted?
|
|
69
|
+
sources = Dir.glob("Gemfile") + Dir.glob("*.gemspec") + Dir.glob("test*/**/*_test.rb").first(5)
|
|
70
|
+
sources.any? { |file| File.read(file).match?(TESTUNIT_HINT) }
|
|
71
|
+
rescue StandardError
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def timing_file
|
|
76
|
+
return DEFAULT_TIMING_FILE unless config_present?
|
|
77
|
+
Config.new.timing_file
|
|
78
|
+
rescue StandardError
|
|
79
|
+
DEFAULT_TIMING_FILE
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Binpacker
|
|
6
|
+
# Builds the machine-readable Run report: predicted versus actual per-Worker
|
|
7
|
+
# durations plus the worst per-Test Drift. See docs/design/run-report-format.md.
|
|
8
|
+
class Report
|
|
9
|
+
SCHEMA = 1
|
|
10
|
+
DRIFT_LIMIT = 10
|
|
11
|
+
|
|
12
|
+
def initialize(profile:, algorithm:, predicted_loads:, worker_stats:, all_timings:, timings:)
|
|
13
|
+
@profile = profile
|
|
14
|
+
@algorithm = algorithm
|
|
15
|
+
@predicted_loads = predicted_loads
|
|
16
|
+
@worker_stats = worker_stats
|
|
17
|
+
@all_timings = all_timings
|
|
18
|
+
@timings = timings
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
predicted = @predicted_loads
|
|
23
|
+
actual = @worker_stats.map { |s| s[:total_time] }
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
schema: SCHEMA,
|
|
27
|
+
profile: @profile,
|
|
28
|
+
algorithm: @algorithm,
|
|
29
|
+
worker_count: @worker_stats.size,
|
|
30
|
+
predicted_makespan: round(predicted.max || 0.0),
|
|
31
|
+
actual_makespan: round(actual.max || 0.0),
|
|
32
|
+
workers: workers,
|
|
33
|
+
balance: {
|
|
34
|
+
predicted_deviation_pct: deviation_pct(predicted),
|
|
35
|
+
actual_deviation_pct: deviation_pct(actual)
|
|
36
|
+
},
|
|
37
|
+
drift: drift
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write(path)
|
|
42
|
+
File.write(path, JSON.pretty_generate(to_h))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def workers
|
|
48
|
+
@worker_stats.map.with_index do |s, i|
|
|
49
|
+
{
|
|
50
|
+
id: i,
|
|
51
|
+
predicted: round(@predicted_loads[i] || 0.0),
|
|
52
|
+
actual: round(s[:total_time]),
|
|
53
|
+
tests: s[:files],
|
|
54
|
+
examples: s[:examples]
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Drift is reported per file (the scheduling unit), so predicted and actual
|
|
60
|
+
# are compared at the same granularity even when timings are recorded
|
|
61
|
+
# per example. Both sides are aggregated to normalized file paths.
|
|
62
|
+
def drift
|
|
63
|
+
actual = aggregate_by_file(@all_timings.map { |e| [e[:file], e[:time]] })
|
|
64
|
+
predicted = aggregate_by_file(@timings.map { |(file, _name), weight| [file, weight] })
|
|
65
|
+
|
|
66
|
+
(actual.keys | predicted.keys)
|
|
67
|
+
.map { |file| drift_entry(file, predicted[file], actual[file]) }
|
|
68
|
+
.sort_by { |d| -(d[:actual] - d[:predicted]).abs }
|
|
69
|
+
.first(DRIFT_LIMIT)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def drift_entry(file, predicted, actual)
|
|
73
|
+
{
|
|
74
|
+
file: file,
|
|
75
|
+
predicted: round(predicted || Timing::DEFAULT_WEIGHT),
|
|
76
|
+
actual: round(actual || 0.0)
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def aggregate_by_file(pairs)
|
|
81
|
+
pairs.each_with_object(Hash.new(0.0)) do |(file, time), acc|
|
|
82
|
+
acc[normalize_file(file)] += time
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_file(file)
|
|
87
|
+
file.to_s.sub(%r{\A\./}, "")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def deviation_pct(loads)
|
|
91
|
+
return 0.0 if loads.empty?
|
|
92
|
+
mean = loads.sum / loads.size
|
|
93
|
+
return 0.0 unless mean.positive?
|
|
94
|
+
max_dev = loads.map { |t| (t - mean).abs }.max
|
|
95
|
+
round(max_dev / mean * 100)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def round(value)
|
|
99
|
+
value.to_f.round(3)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/binpacker/scheduler.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module Binpacker
|
|
4
4
|
class Scheduler
|
|
5
|
-
# Returns Array<WorkerQueue> one per worker.
|
|
6
5
|
def partition(tests:, worker_count:, timings:)
|
|
7
6
|
raise NotImplementedError
|
|
8
7
|
end
|
|
@@ -10,28 +9,94 @@ module Binpacker
|
|
|
10
9
|
def self.for(strategy)
|
|
11
10
|
case strategy.to_s
|
|
12
11
|
when "lpt" then LptScheduler.new
|
|
12
|
+
when "multifit" then MultifitScheduler.new
|
|
13
13
|
else
|
|
14
14
|
raise SchedulerError, "unknown scheduling algorithm: #{strategy}"
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def weight(test, timings)
|
|
21
|
+
timings.fetch(test.key, Timing::DEFAULT_WEIGHT)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def sorted_by_weight(tests, timings)
|
|
25
|
+
tests.sort_by { |t| -weight(t, timings) }
|
|
26
|
+
end
|
|
17
27
|
end
|
|
18
28
|
|
|
19
29
|
class LptScheduler < Scheduler
|
|
20
|
-
# Longest Processing Time first.
|
|
21
|
-
# Sort tests by descending weight, assign each to the least-loaded worker.
|
|
22
30
|
def partition(tests:, worker_count:, timings:)
|
|
23
31
|
queues = Array.new(worker_count) { |i| WorkerQueue.new(i) }
|
|
24
32
|
loads = Array.new(worker_count, 0.0)
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
sorted = tests.sort_by { |t|
|
|
28
|
-
-timings.fetch(t.key, Timing::DEFAULT_WEIGHT)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
sorted.each do |test|
|
|
34
|
+
sorted_by_weight(tests, timings).each do |test|
|
|
32
35
|
min_idx = loads.each_with_index.min_by { |load, _| load }.last
|
|
33
36
|
queues[min_idx].push(test)
|
|
34
|
-
loads[min_idx] +=
|
|
37
|
+
loads[min_idx] += weight(test, timings)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
queues
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class MultifitScheduler < Scheduler
|
|
45
|
+
ITERATIONS = 7
|
|
46
|
+
|
|
47
|
+
def partition(tests:, worker_count:, timings:)
|
|
48
|
+
sorted = sorted_by_weight(tests, timings)
|
|
49
|
+
|
|
50
|
+
upper = lpt_makespan(sorted, worker_count, timings)
|
|
51
|
+
lower = [max_weight(sorted, timings), total_weight(sorted, timings) / worker_count.to_f].max
|
|
52
|
+
|
|
53
|
+
best_queues = nil
|
|
54
|
+
ITERATIONS.times do
|
|
55
|
+
mid = (upper + lower) / 2.0
|
|
56
|
+
queues = first_fit_decreasing(sorted, worker_count, mid, timings)
|
|
57
|
+
|
|
58
|
+
if queues
|
|
59
|
+
best_queues = queues
|
|
60
|
+
upper = mid
|
|
61
|
+
else
|
|
62
|
+
lower = mid
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
best_queues || LptScheduler.new.partition(tests: tests, worker_count: worker_count, timings: timings)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def lpt_makespan(sorted, worker_count, timings)
|
|
72
|
+
loads = Array.new(worker_count, 0.0)
|
|
73
|
+
sorted.each do |test|
|
|
74
|
+
min_idx = loads.each_with_index.min_by { |l, _| l }.last
|
|
75
|
+
loads[min_idx] += weight(test, timings)
|
|
76
|
+
end
|
|
77
|
+
loads.max
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def max_weight(sorted, timings)
|
|
81
|
+
sorted.map { |t| weight(t, timings) }.max || 0.0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def total_weight(sorted, timings)
|
|
85
|
+
sorted.sum { |t| weight(t, timings) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def first_fit_decreasing(sorted, worker_count, capacity, timings)
|
|
89
|
+
queues = Array.new(worker_count) { |i| WorkerQueue.new(i) }
|
|
90
|
+
loads = Array.new(worker_count, 0.0)
|
|
91
|
+
|
|
92
|
+
sorted.each do |test|
|
|
93
|
+
w = weight(test, timings)
|
|
94
|
+
idx = loads.each_with_index.find { |l, _| l + w <= capacity }&.last
|
|
95
|
+
|
|
96
|
+
return nil unless idx
|
|
97
|
+
|
|
98
|
+
queues[idx].push(test)
|
|
99
|
+
loads[idx] += w
|
|
35
100
|
end
|
|
36
101
|
|
|
37
102
|
queues
|