binpacker 0.3.0 → 0.4.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 +12 -2
- data/lib/binpacker/orchestrator.rb +85 -37
- data/lib/binpacker/progress.rb +15 -9
- data/lib/binpacker/timing.rb +122 -24
- data/lib/binpacker/version.rb +1 -1
- data/lib/binpacker/worker_queue.rb +1 -1
- data/sig/binpacker/orchestrator.rbs +22 -0
- data/sig/binpacker/timing.rbs +6 -0
- data/sig/binpacker/worker_queue.rbs +2 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98842f3601fe059ff372b62a214ee73e5e96d0b2c01cf5b651063f04e3f00184
|
|
4
|
+
data.tar.gz: 94e73ac26474bf1da1942509f35618e8576d230b685ee69fe09579d4b9387898
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a983e121b41c7a9e62dad700ee5541813b84c2553e7fc479c3dbedf996358669a9508d2fd7f3eb8df21831c583421ee91382a453ac73a60beead9bf03da0675
|
|
7
|
+
data.tar.gz: 657ac41276da7094a5ea54d53936a9237b1b3139f53b741c954427b57f2d781dca56df91746972e33e93f49edb7a93a2155d5b92561653d672f87ca73373669a
|
data/exe/binpacker-worker
CHANGED
|
@@ -166,6 +166,16 @@ end.parse!
|
|
|
166
166
|
|
|
167
167
|
wid = options[:id]
|
|
168
168
|
exit_code = 0
|
|
169
|
+
|
|
170
|
+
# Test code must never see the orchestrator's control pipe. A test
|
|
171
|
+
# that reads stdin — an LSP server loop, anything calling gets —
|
|
172
|
+
# would block forever on the pipe the orchestrator keeps open
|
|
173
|
+
# between dynamic batches (or, worse, consume control messages).
|
|
174
|
+
# Keep the pipe on a private handle and point process stdin, which
|
|
175
|
+
# spawned test runners inherit, at /dev/null so readers see EOF.
|
|
176
|
+
control = $stdin.dup
|
|
177
|
+
$stdin.reopen(File::NULL)
|
|
178
|
+
|
|
169
179
|
$stderr.puts "[worker-#{wid}] #{options[:runner]} worker started"
|
|
170
180
|
|
|
171
181
|
loop do
|
|
@@ -174,7 +184,7 @@ loop do
|
|
|
174
184
|
|
|
175
185
|
tests = []
|
|
176
186
|
done_or_eof = false
|
|
177
|
-
|
|
187
|
+
control.each_line do |line|
|
|
178
188
|
data = JSON.parse(line.strip)
|
|
179
189
|
if data["type"] == "done"
|
|
180
190
|
done_or_eof = true
|
|
@@ -186,7 +196,7 @@ loop do
|
|
|
186
196
|
end
|
|
187
197
|
|
|
188
198
|
break if done_or_eof && tests.empty?
|
|
189
|
-
break if tests.empty? && (
|
|
199
|
+
break if tests.empty? && (control.eof? || control.closed?)
|
|
190
200
|
|
|
191
201
|
files = tests.map { |t| t["file"] }.uniq
|
|
192
202
|
$stderr.puts "[worker-#{wid}] batch: #{files.size} files, #{tests.size} entries"
|
|
@@ -2,7 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
module Binpacker
|
|
4
4
|
class Orchestrator
|
|
5
|
-
|
|
5
|
+
# Dynamic batches halve the queue's remaining predicted weight,
|
|
6
|
+
# floored at a per-run minimum: early batches are large to amortize
|
|
7
|
+
# the per-batch test-runner boot (each batch is a fresh rspec
|
|
8
|
+
# process), tail batches stay small so workers finish together.
|
|
9
|
+
#
|
|
10
|
+
# The floor comes in two regimes (computed by #min_batch_weight in
|
|
11
|
+
# #run and threaded into #drain_batch):
|
|
12
|
+
# - Calibrated: weights are measured seconds, so the floor is a
|
|
13
|
+
# fixed MIN_BATCH_WEIGHT (~30s), an order of magnitude above the
|
|
14
|
+
# ~3s runner boot — boot overhead stays in the noise.
|
|
15
|
+
# - Cold start: weights are filesize KB, not seconds, so a fixed
|
|
16
|
+
# 30 is meaningless. We instead derive a floor that targets
|
|
17
|
+
# ~COLD_START_BATCHES_PER_WORKER batches per worker (below).
|
|
18
|
+
MIN_BATCH_WEIGHT = 30.0
|
|
19
|
+
|
|
20
|
+
# On a pure cold start predicted weights are filesize KB, not
|
|
21
|
+
# seconds, so the 30s MIN_BATCH_WEIGHT floor has no meaning. Instead
|
|
22
|
+
# we size the floor so the total predicted weight divides into about
|
|
23
|
+
# this many batches per worker — matching the ~5 runner boots per
|
|
24
|
+
# worker that gave the best makespan in the PR #12 simulation.
|
|
25
|
+
COLD_START_BATCHES_PER_WORKER = 5
|
|
6
26
|
|
|
7
27
|
def initialize(config, passthrough: [], quiet: false, report_path: nil)
|
|
8
28
|
@config = config
|
|
@@ -17,7 +37,7 @@ module Binpacker
|
|
|
17
37
|
timings = timing.load_with_fallback(tests)
|
|
18
38
|
@timings = timings
|
|
19
39
|
|
|
20
|
-
scheduler = Scheduler.for(@config.scheduler[
|
|
40
|
+
scheduler = Scheduler.for(@config.scheduler['algorithm'])
|
|
21
41
|
queues = scheduler.partition(
|
|
22
42
|
tests: tests,
|
|
23
43
|
worker_count: @config.worker_count,
|
|
@@ -28,12 +48,12 @@ module Binpacker
|
|
|
28
48
|
@predicted_loads = queues.map { |q| q.total_weight(timings) }
|
|
29
49
|
|
|
30
50
|
runner_class = TestRunner.for(@config.test_runner)
|
|
31
|
-
workers = queues.map.with_index do |
|
|
51
|
+
workers = queues.map.with_index do |_queue, idx|
|
|
32
52
|
Worker.new(idx, runner_class, passthrough: @passthrough, quiet: @quiet).tap(&:start)
|
|
33
53
|
end
|
|
34
54
|
|
|
35
|
-
if @config.scheduler[
|
|
36
|
-
run_dynamic(workers, queues, timing, tests)
|
|
55
|
+
if @config.scheduler['steal_enabled']
|
|
56
|
+
run_dynamic(workers, queues, timing, tests, min_batch_weight(timing, timings))
|
|
37
57
|
else
|
|
38
58
|
run_static(workers, queues, timing, tests)
|
|
39
59
|
end
|
|
@@ -43,9 +63,9 @@ module Binpacker
|
|
|
43
63
|
|
|
44
64
|
def discover
|
|
45
65
|
case @config.test_runner
|
|
46
|
-
when
|
|
66
|
+
when 'rspec'
|
|
47
67
|
RSpecDiscovery.new(@config).enumerate
|
|
48
|
-
when
|
|
68
|
+
when 'minitest'
|
|
49
69
|
MinitestDiscovery.new(@config).enumerate
|
|
50
70
|
else
|
|
51
71
|
raise ConfigError, "unsupported runner: #{@config.test_runner}"
|
|
@@ -58,7 +78,7 @@ module Binpacker
|
|
|
58
78
|
|
|
59
79
|
workers.zip(queues).each do |worker, queue|
|
|
60
80
|
worker.send_tests(queue.remaining)
|
|
61
|
-
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file ||
|
|
81
|
+
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file || '')
|
|
62
82
|
end
|
|
63
83
|
progress.refresh
|
|
64
84
|
|
|
@@ -81,10 +101,10 @@ module Binpacker
|
|
|
81
101
|
worker_examples[worker.id] = worker.example_count
|
|
82
102
|
worker_passed[worker.id] = worker.passed_count
|
|
83
103
|
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:
|
|
104
|
+
progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file: 'done')
|
|
85
105
|
progress.refresh
|
|
86
106
|
rescue WorkerError => e
|
|
87
|
-
|
|
107
|
+
warn "worker #{worker.id} error: #{e.message}"
|
|
88
108
|
all_passed = false
|
|
89
109
|
ensure
|
|
90
110
|
worker.cleanup
|
|
@@ -92,7 +112,7 @@ module Binpacker
|
|
|
92
112
|
|
|
93
113
|
progress.finish
|
|
94
114
|
|
|
95
|
-
worker_stats = workers.map.with_index do |
|
|
115
|
+
worker_stats = workers.map.with_index do |_w, i|
|
|
96
116
|
{
|
|
97
117
|
files: queue_totals[i],
|
|
98
118
|
total_time: worker_time[i],
|
|
@@ -106,11 +126,9 @@ module Binpacker
|
|
|
106
126
|
finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
|
|
107
127
|
end
|
|
108
128
|
|
|
109
|
-
def run_dynamic(workers, queues, timing, tests)
|
|
129
|
+
def run_dynamic(workers, queues, timing, tests, floor)
|
|
110
130
|
all_timings = []
|
|
111
131
|
all_passed = true
|
|
112
|
-
total_examples = 0
|
|
113
|
-
passed_examples = 0
|
|
114
132
|
active = []
|
|
115
133
|
|
|
116
134
|
queue_totals = queues.map(&:size)
|
|
@@ -123,25 +141,23 @@ module Binpacker
|
|
|
123
141
|
progress = ProgressDisplay.new(workers.size)
|
|
124
142
|
|
|
125
143
|
workers.zip(queues).each do |worker, queue|
|
|
126
|
-
batch = drain_batch(queue)
|
|
144
|
+
batch = drain_batch(queue, floor)
|
|
127
145
|
if batch.empty?
|
|
128
146
|
worker.signal_done
|
|
129
147
|
worker.collect_results
|
|
130
148
|
all_timings.concat(worker.timings)
|
|
131
149
|
all_passed &&= worker.success?
|
|
132
|
-
total_examples += worker.example_count
|
|
133
|
-
passed_examples += worker.passed_count
|
|
134
150
|
worker_examples[worker.id] = worker.example_count
|
|
135
151
|
worker_passed[worker.id] = worker.passed_count
|
|
136
152
|
worker.cleanup
|
|
137
153
|
worker_done[worker.id] = queue_totals[worker.id]
|
|
138
|
-
progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file:
|
|
154
|
+
progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file: 'done')
|
|
139
155
|
else
|
|
140
156
|
worker.send_tests(batch)
|
|
141
157
|
worker.batch_done
|
|
142
158
|
active << worker
|
|
143
159
|
batch_sizes[worker.id] = batch.size
|
|
144
|
-
current_file = batch.first&.file ||
|
|
160
|
+
current_file = batch.first&.file || ''
|
|
145
161
|
progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: current_file)
|
|
146
162
|
end
|
|
147
163
|
end
|
|
@@ -149,33 +165,38 @@ module Binpacker
|
|
|
149
165
|
until active.empty?
|
|
150
166
|
ready = active.find { |w| w.wait_for_batch }
|
|
151
167
|
unless ready
|
|
152
|
-
active.reject! { |w|
|
|
168
|
+
active.reject! { |w| %i[crashed error].include?(w.status) }
|
|
153
169
|
sleep 0.1
|
|
154
170
|
next
|
|
155
171
|
end
|
|
156
172
|
|
|
157
173
|
begin
|
|
158
174
|
all_passed &&= ready.success?
|
|
159
|
-
total_examples += ready.example_count
|
|
160
|
-
passed_examples += ready.passed_count
|
|
161
175
|
|
|
162
176
|
worker_done[ready.id] += batch_sizes[ready.id]
|
|
177
|
+
# Worker#example_count accumulates across batches, so these
|
|
178
|
+
# are running totals per worker; the grand totals are summed
|
|
179
|
+
# once after the loop, never added per batch.
|
|
163
180
|
worker_examples[ready.id] = ready.example_count
|
|
164
181
|
worker_passed[ready.id] = ready.passed_count
|
|
165
182
|
|
|
166
183
|
own_queue = queues[ready.id]
|
|
167
|
-
next_batch = drain_batch(own_queue)
|
|
184
|
+
next_batch = drain_batch(own_queue, floor)
|
|
168
185
|
|
|
169
186
|
if next_batch.empty?
|
|
170
|
-
donor = queues.reject(&:empty?).max_by(
|
|
171
|
-
|
|
187
|
+
donor = queues.reject(&:empty?).max_by { |q| q.total_weight(@timings) }
|
|
188
|
+
if donor
|
|
189
|
+
next_batch = drain_batch(donor, floor)
|
|
190
|
+
queue_totals[ready.id] += next_batch.size
|
|
191
|
+
queue_totals[donor.worker_id] -= next_batch.size
|
|
192
|
+
end
|
|
172
193
|
end
|
|
173
194
|
|
|
174
195
|
if next_batch.any?
|
|
175
196
|
ready.send_tests(next_batch)
|
|
176
197
|
ready.batch_done
|
|
177
198
|
batch_sizes[ready.id] = next_batch.size
|
|
178
|
-
current_file = next_batch.first&.file ||
|
|
199
|
+
current_file = next_batch.first&.file || ''
|
|
179
200
|
progress.update(ready.id, done: worker_done[ready.id], total: queue_totals[ready.id], file: current_file)
|
|
180
201
|
progress.refresh
|
|
181
202
|
else
|
|
@@ -183,11 +204,11 @@ module Binpacker
|
|
|
183
204
|
all_timings.concat(ready.timings)
|
|
184
205
|
active.delete(ready)
|
|
185
206
|
worker_done[ready.id] = queue_totals[ready.id]
|
|
186
|
-
progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file:
|
|
207
|
+
progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file: 'done')
|
|
187
208
|
progress.refresh
|
|
188
209
|
end
|
|
189
210
|
rescue WorkerError => e
|
|
190
|
-
|
|
211
|
+
warn "worker #{ready.id} error: #{e.message}"
|
|
191
212
|
all_passed = false
|
|
192
213
|
active.delete(ready)
|
|
193
214
|
end
|
|
@@ -195,6 +216,9 @@ module Binpacker
|
|
|
195
216
|
|
|
196
217
|
progress.finish
|
|
197
218
|
|
|
219
|
+
total_examples = worker_examples.sum
|
|
220
|
+
passed_examples = worker_passed.sum
|
|
221
|
+
|
|
198
222
|
worker_stats = workers.map.with_index do |w, i|
|
|
199
223
|
tw = w.timings.sum { |t| t[:time] }
|
|
200
224
|
{
|
|
@@ -216,7 +240,7 @@ module Binpacker
|
|
|
216
240
|
|
|
217
241
|
Report.new(
|
|
218
242
|
profile: @config.profile,
|
|
219
|
-
algorithm: @config.scheduler[
|
|
243
|
+
algorithm: @config.scheduler['algorithm'],
|
|
220
244
|
predicted_loads: @predicted_loads,
|
|
221
245
|
worker_stats: worker_stats,
|
|
222
246
|
all_timings: all_timings,
|
|
@@ -224,19 +248,43 @@ module Binpacker
|
|
|
224
248
|
).write(@report_path)
|
|
225
249
|
end
|
|
226
250
|
|
|
227
|
-
|
|
251
|
+
# Per-run batch-weight floor. Calibrated runs use the fixed
|
|
252
|
+
# seconds-scale MIN_BATCH_WEIGHT. On a cold start weights are KB, so
|
|
253
|
+
# that floor is meaningless; derive one that splits the total
|
|
254
|
+
# predicted weight into ~COLD_START_BATCHES_PER_WORKER batches per
|
|
255
|
+
# worker, falling back to MIN_BATCH_WEIGHT when there is nothing to
|
|
256
|
+
# divide (zero total weight or zero workers).
|
|
257
|
+
def min_batch_weight(timing, timings)
|
|
258
|
+
return MIN_BATCH_WEIGHT if timing.calibrated?
|
|
259
|
+
|
|
260
|
+
total = timings.values.sum(0.0)
|
|
261
|
+
workers = @config.worker_count
|
|
262
|
+
return MIN_BATCH_WEIGHT if total.zero? || workers.zero?
|
|
263
|
+
|
|
264
|
+
total / (workers * COLD_START_BATCHES_PER_WORKER)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Drains the next batch off +queue+: enough tests to halve its
|
|
268
|
+
# remaining predicted weight, but never less than +floor+ (see
|
|
269
|
+
# #min_batch_weight) and never fewer than one test.
|
|
270
|
+
def drain_batch(queue, floor)
|
|
228
271
|
return [] if queue.nil? || queue.empty?
|
|
272
|
+
|
|
273
|
+
target = [queue.total_weight(@timings) / 2.0, floor].max
|
|
229
274
|
batch = []
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
break unless test
|
|
275
|
+
weight = 0.0
|
|
276
|
+
while (batch.empty? || weight < target) && (test = queue.pop)
|
|
233
277
|
batch << test
|
|
278
|
+
weight += @timings.fetch(test.key, Timing::DEFAULT_WEIGHT)
|
|
234
279
|
end
|
|
235
280
|
batch
|
|
236
281
|
end
|
|
237
282
|
|
|
238
283
|
def finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
|
|
239
|
-
|
|
284
|
+
unless all_timings.empty?
|
|
285
|
+
timing.append_all(all_timings)
|
|
286
|
+
timing.compact!
|
|
287
|
+
end
|
|
240
288
|
empty_filter = minitest_empty_filter?(tests, total_examples)
|
|
241
289
|
all_passed = false if empty_filter
|
|
242
290
|
|
|
@@ -250,7 +298,7 @@ module Binpacker
|
|
|
250
298
|
end
|
|
251
299
|
|
|
252
300
|
def minitest_empty_filter?(tests, total_examples)
|
|
253
|
-
return false unless @config.test_runner ==
|
|
301
|
+
return false unless @config.test_runner == 'minitest'
|
|
254
302
|
return false unless tests.any?
|
|
255
303
|
return false unless total_examples.zero?
|
|
256
304
|
|
|
@@ -260,8 +308,8 @@ module Binpacker
|
|
|
260
308
|
def minitest_include_filter?
|
|
261
309
|
@passthrough.any? do |arg|
|
|
262
310
|
%w[--name --include -n -i].include?(arg) ||
|
|
263
|
-
arg.start_with?(
|
|
264
|
-
(arg.start_with?(
|
|
311
|
+
arg.start_with?('--name=', '--include=') ||
|
|
312
|
+
(arg.start_with?('-n', '-i') && arg.length > 2)
|
|
265
313
|
end
|
|
266
314
|
end
|
|
267
315
|
end
|
data/lib/binpacker/progress.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Binpacker
|
|
|
7
7
|
def initialize(worker_count, tty: $stdout.tty?)
|
|
8
8
|
@worker_count = worker_count
|
|
9
9
|
@tty = tty
|
|
10
|
-
@workers = Array.new(worker_count) { { done: 0, total: 0, file:
|
|
10
|
+
@workers = Array.new(worker_count) { { done: 0, total: 0, file: '', elapsed: 0.0 } }
|
|
11
11
|
@start = Time.now
|
|
12
12
|
@last_ci_output = Time.at(Time.now.to_f - CI_INTERVAL)
|
|
13
13
|
@lines_written = 0
|
|
@@ -32,8 +32,9 @@ module Binpacker
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def finish(
|
|
35
|
+
def finish(_worker_stats = [])
|
|
36
36
|
return unless @tty
|
|
37
|
+
|
|
37
38
|
redraw
|
|
38
39
|
$stdout.puts
|
|
39
40
|
end
|
|
@@ -42,9 +43,10 @@ module Binpacker
|
|
|
42
43
|
active = worker_stats.reject { |s| s[:files] == 0 && s[:examples] == 0 }
|
|
43
44
|
return if active.empty?
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
worker_stats.each_with_index do |s, wid|
|
|
47
|
+
next if s[:files] == 0 && s[:examples] == 0
|
|
48
|
+
|
|
46
49
|
t = format_time(s[:total_time])
|
|
47
|
-
wid = worker_stats.index(s)
|
|
48
50
|
$stdout.puts " Worker #{wid}: #{s[:files]} tests, #{t} | #{s[:examples]} examples, #{s[:passed]} passed"
|
|
49
51
|
end
|
|
50
52
|
total_tests = active.sum { |s| s[:files] }
|
|
@@ -55,7 +57,7 @@ module Binpacker
|
|
|
55
57
|
max_dev = times.map { |t| (t - mean).abs }.max
|
|
56
58
|
dev_pct = mean > 0 ? (max_dev / mean * 100).round(1) : 0
|
|
57
59
|
|
|
58
|
-
$stdout.puts
|
|
60
|
+
$stdout.puts ' ──'
|
|
59
61
|
$stdout.puts " Total: #{total_tests} tests, #{format_time(total_time)} | #{total_examples} examples"
|
|
60
62
|
$stdout.puts " Balance: max deviation #{format_time(max_dev)} (#{dev_pct}%)"
|
|
61
63
|
end
|
|
@@ -67,7 +69,7 @@ module Binpacker
|
|
|
67
69
|
clear_lines
|
|
68
70
|
@workers.each_with_index do |w, i|
|
|
69
71
|
bar = build_bar(w[:done], w[:total])
|
|
70
|
-
status = w[:total] > 0 && w[:done] >= w[:total] ?
|
|
72
|
+
status = w[:total] > 0 && w[:done] >= w[:total] ? 'done' : w[:file][-50..] || ''
|
|
71
73
|
$stdout.puts format_line(i, bar, w[:done], w[:total], status, w[:elapsed])
|
|
72
74
|
end
|
|
73
75
|
@lines_written = @worker_count
|
|
@@ -76,13 +78,15 @@ module Binpacker
|
|
|
76
78
|
|
|
77
79
|
def clear_lines
|
|
78
80
|
return if @lines_written == 0
|
|
81
|
+
|
|
79
82
|
@lines_written.times do
|
|
80
83
|
$stdout.print "\033[A\033[K"
|
|
81
84
|
end
|
|
82
85
|
end
|
|
83
86
|
|
|
84
87
|
def build_bar(done, total)
|
|
85
|
-
return
|
|
88
|
+
return '[----------]' if total == 0
|
|
89
|
+
|
|
86
90
|
width = 10
|
|
87
91
|
filled = (done.to_f / total * width).round
|
|
88
92
|
"[#{'█' * filled}#{'░' * (width - filled)}]"
|
|
@@ -96,10 +100,11 @@ module Binpacker
|
|
|
96
100
|
def periodic_output
|
|
97
101
|
now = Time.now
|
|
98
102
|
return if now - @last_ci_output < CI_INTERVAL
|
|
103
|
+
|
|
99
104
|
@last_ci_output = now
|
|
100
105
|
|
|
101
106
|
parts = @workers.map.with_index do |w, i|
|
|
102
|
-
ratio = w[:total] > 0 ? "#{w[:done]}/#{w[:total]}" :
|
|
107
|
+
ratio = w[:total] > 0 ? "#{w[:done]}/#{w[:total]}" : '0/?'
|
|
103
108
|
"W#{i}: #{ratio}"
|
|
104
109
|
end
|
|
105
110
|
elapsed = (now - @start).round(1)
|
|
@@ -108,7 +113,8 @@ module Binpacker
|
|
|
108
113
|
end
|
|
109
114
|
|
|
110
115
|
def format_time(seconds)
|
|
111
|
-
return
|
|
116
|
+
return ' 0.0s' if seconds < 0.001
|
|
117
|
+
|
|
112
118
|
m = (seconds / 60).floor
|
|
113
119
|
s = (seconds % 60).round(1)
|
|
114
120
|
m > 0 ? "#{m}m#{s.to_s.rjust(4, '0')}s" : "#{s.to_s.rjust(5)}s"
|
data/lib/binpacker/timing.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
|
class Timing
|
|
@@ -8,40 +8,69 @@ module Binpacker
|
|
|
8
8
|
|
|
9
9
|
DEFAULT_WEIGHT = 1.0
|
|
10
10
|
|
|
11
|
+
# Samples retained per test by #compact! and consulted by the
|
|
12
|
+
# median in #load_per_file. Three samples make a single anomalous
|
|
13
|
+
# run (GC pause, noisy CI neighbour) unable to move the weight.
|
|
14
|
+
MAX_SAMPLES_PER_TEST = 3
|
|
15
|
+
|
|
11
16
|
def initialize(path)
|
|
12
17
|
@path = Pathname(path)
|
|
13
18
|
end
|
|
14
19
|
|
|
20
|
+
# True once any timing samples exist, i.e. the project has been
|
|
21
|
+
# calibrated at least once. Callers use this to tell a measured
|
|
22
|
+
# run (weights in seconds) from a pure cold start (fallbacks only).
|
|
23
|
+
def calibrated?
|
|
24
|
+
!samples_by_test.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
15
27
|
def load_with_fallback(tests)
|
|
16
28
|
per_file = load_per_file
|
|
29
|
+
coefficient = seconds_per_kb(per_file)
|
|
17
30
|
tests.each_with_object({}) do |test, hash|
|
|
18
31
|
key = normalize_path(test.file)
|
|
19
|
-
hash[test.key] = per_file.fetch(key) {
|
|
32
|
+
hash[test.key] = per_file.fetch(key) { fallback_weight(test.file, coefficient) }
|
|
20
33
|
end
|
|
21
34
|
end
|
|
22
35
|
|
|
23
36
|
def load_raw
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@path.each_line(encoding: "UTF-8")
|
|
27
|
-
.map { |line| parse_line(line) }
|
|
28
|
-
.compact
|
|
29
|
-
.group_by { |e| [normalize_path(e.file), e.name] }
|
|
30
|
-
.transform_values { |entries| entries.last.time }
|
|
37
|
+
@load_raw ||= samples_by_test.transform_values(&:last)
|
|
31
38
|
end
|
|
32
39
|
|
|
40
|
+
# Predicted weight per file: the median of each test's recent
|
|
41
|
+
# samples, summed per file. The append-only history must NOT be
|
|
42
|
+
# summed wholesale — a file present in N historical runs would
|
|
43
|
+
# weigh ~N times its true cost, so long-lived files dominate and
|
|
44
|
+
# newly added ones are starved, skewing the partition.
|
|
33
45
|
def load_per_file
|
|
34
|
-
|
|
46
|
+
samples_by_test.each_with_object({}) do |((file, _name), times), per_file|
|
|
47
|
+
weight = median(times.last(MAX_SAMPLES_PER_TEST))
|
|
48
|
+
per_file[file] = per_file.fetch(file, 0.0) + weight
|
|
49
|
+
end
|
|
50
|
+
end
|
|
35
51
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
# Rewrites the timing file keeping only the most recent
|
|
53
|
+
# MAX_SAMPLES_PER_TEST samples per test, so the append-only
|
|
54
|
+
# history (and any CI cache built from it) stays bounded instead
|
|
55
|
+
# of growing by one run per invocation.
|
|
56
|
+
def compact!
|
|
57
|
+
samples = samples_by_test
|
|
58
|
+
return if samples.empty?
|
|
59
|
+
|
|
60
|
+
tmp = Pathname("#{@path}.tmp")
|
|
61
|
+
tmp.open('w', encoding: 'UTF-8') do |io|
|
|
62
|
+
samples.each do |(file, name), times|
|
|
63
|
+
times.last(MAX_SAMPLES_PER_TEST).each do |time|
|
|
64
|
+
io.puts JSON.generate({ file: file, name: name, time: time })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
File.rename(tmp.to_s, @path.to_s)
|
|
69
|
+
invalidate
|
|
41
70
|
end
|
|
42
71
|
|
|
43
72
|
def normalize_path(path)
|
|
44
|
-
Pathname(path).cleanpath.to_s.sub(
|
|
73
|
+
Pathname(path).cleanpath.to_s.sub(%r{\A\./}, '')
|
|
45
74
|
end
|
|
46
75
|
|
|
47
76
|
# True when a measured Weight already exists for this Test.
|
|
@@ -49,34 +78,103 @@ module Binpacker
|
|
|
49
78
|
load_raw.key?([normalize_path(file), name])
|
|
50
79
|
end
|
|
51
80
|
|
|
52
|
-
def weight_for(file:, name:)
|
|
53
|
-
measured = load_raw
|
|
54
|
-
measured.fetch([file, name]) { filesize_weight(file) }
|
|
55
|
-
end
|
|
56
|
-
|
|
57
81
|
def append(file:, name:, time:)
|
|
58
82
|
@path.dirname.mkpath unless @path.dirname.directory?
|
|
59
|
-
@path.open(
|
|
83
|
+
@path.open('a', encoding: 'UTF-8') { |io| io.puts JSON.generate({ file: file, name: name, time: time }) }
|
|
84
|
+
invalidate
|
|
60
85
|
end
|
|
61
86
|
|
|
62
87
|
def append_all(entries)
|
|
63
88
|
return if entries.empty?
|
|
89
|
+
|
|
64
90
|
@path.dirname.mkpath unless @path.dirname.directory?
|
|
65
|
-
@path.open(
|
|
91
|
+
@path.open('a', encoding: 'UTF-8') do |io|
|
|
66
92
|
entries.each { |e| io.puts JSON.generate({ file: e[:file], name: e[:name], time: e[:time] }) }
|
|
67
93
|
end
|
|
94
|
+
invalidate
|
|
68
95
|
end
|
|
69
96
|
|
|
70
97
|
private
|
|
71
98
|
|
|
99
|
+
# [normalized file, name] => [sample, ...] in append (= run) order.
|
|
100
|
+
def samples_by_test
|
|
101
|
+
@samples_by_test ||= begin
|
|
102
|
+
samples = Hash.new { |h, k| h[k] = [] }
|
|
103
|
+
if @path.exist?
|
|
104
|
+
@path.each_line(encoding: 'UTF-8') do |line|
|
|
105
|
+
entry = parse_line(line)
|
|
106
|
+
samples[[normalize_path(entry.file), entry.name]] << entry.time if entry
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
samples.default_proc = nil
|
|
110
|
+
samples
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def invalidate
|
|
115
|
+
@samples_by_test = nil
|
|
116
|
+
@load_raw = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def median(values)
|
|
120
|
+
sorted = values.sort
|
|
121
|
+
mid = sorted.size / 2
|
|
122
|
+
sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
123
|
+
end
|
|
124
|
+
|
|
72
125
|
def filesize_weight(file)
|
|
73
126
|
path = Pathname(file)
|
|
74
127
|
path.exist? ? [path.size / 1024.0, DEFAULT_WEIGHT].max : DEFAULT_WEIGHT
|
|
75
128
|
end
|
|
76
129
|
|
|
130
|
+
# Seconds-per-KB coefficient used to scale filesize fallbacks onto
|
|
131
|
+
# the same axis as measured Weights. Measured Weights are seconds;
|
|
132
|
+
# the raw filesize fallback is KB, so mixing them (batch floors,
|
|
133
|
+
# donor selection) compares apples to oranges — 30s of predicted
|
|
134
|
+
# work reads as "30 KB of files". We estimate a conversion from
|
|
135
|
+
# the files we DO have timings for: for each measured file that
|
|
136
|
+
# still exists on disk with size > 0, take measured_seconds /
|
|
137
|
+
# size_kb, and use the median of those ratios (robust to a few
|
|
138
|
+
# outlier files that are unusually fast or slow for their size).
|
|
139
|
+
# nil when nothing can be estimated (no measurements, or none of
|
|
140
|
+
# the measured files exist on disk) — callers then keep raw KB.
|
|
141
|
+
def seconds_per_kb(per_file)
|
|
142
|
+
ratios = per_file.filter_map do |file, seconds|
|
|
143
|
+
kb = size_kb(file)
|
|
144
|
+
next if kb.nil? || kb <= 0
|
|
145
|
+
|
|
146
|
+
seconds / kb
|
|
147
|
+
end
|
|
148
|
+
ratios.empty? ? nil : median(ratios)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Predicted Weight for an unmeasured Test. Without a coefficient we
|
|
152
|
+
# keep the legacy raw-KB behavior (floored at 1.0). With one we
|
|
153
|
+
# scale filesize into seconds: an existing file becomes
|
|
154
|
+
# size_kb * coefficient (floored at 0.01 to avoid degenerate zero
|
|
155
|
+
# weights, but NOT at 1.0 — sub-second predictions are meaningful
|
|
156
|
+
# now that the unit is seconds); a missing/unreadable file falls
|
|
157
|
+
# back to DEFAULT_WEIGHT as a plausible seconds-scale default.
|
|
158
|
+
def fallback_weight(file, coefficient)
|
|
159
|
+
return filesize_weight(file) if coefficient.nil?
|
|
160
|
+
|
|
161
|
+
kb = size_kb(file)
|
|
162
|
+
return DEFAULT_WEIGHT if kb.nil?
|
|
163
|
+
|
|
164
|
+
[kb * coefficient, 0.01].max
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# File size in KB, or nil when the file does not exist / is unreadable.
|
|
168
|
+
def size_kb(file)
|
|
169
|
+
path = Pathname(file)
|
|
170
|
+
path.exist? ? path.size / 1024.0 : nil
|
|
171
|
+
rescue SystemCallError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
77
175
|
def parse_line(line)
|
|
78
176
|
data = JSON.parse(line.strip)
|
|
79
|
-
Entry.new(file: data[
|
|
177
|
+
Entry.new(file: data['file'], name: data['name'], time: data['time'])
|
|
80
178
|
rescue JSON::ParserError
|
|
81
179
|
nil
|
|
82
180
|
end
|
data/lib/binpacker/version.rb
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
module Binpacker
|
|
2
2
|
class Orchestrator
|
|
3
|
+
MIN_BATCH_WEIGHT: Float
|
|
4
|
+
COLD_START_BATCHES_PER_WORKER: Integer
|
|
5
|
+
|
|
6
|
+
@config: untyped
|
|
7
|
+
@passthrough: untyped
|
|
8
|
+
@quiet: untyped
|
|
9
|
+
@report_path: untyped
|
|
10
|
+
@timings: Hash[untyped, Float]
|
|
11
|
+
@predicted_loads: Array[Float]
|
|
12
|
+
|
|
3
13
|
def initialize: (untyped, ?passthrough: untyped, ?quiet: untyped, ?report_path: untyped) -> void
|
|
4
14
|
def run: () -> { passed: untyped, total: untyped, passed_count: untyped, timings: Array[untyped], empty_filter: untyped }
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def discover: () -> untyped
|
|
19
|
+
def run_static: (untyped, untyped, Binpacker::Timing, untyped) -> untyped
|
|
20
|
+
def run_dynamic: (untyped, untyped, Binpacker::Timing, untyped, Float) -> untyped
|
|
21
|
+
def write_report: (untyped, Array[untyped]) -> void
|
|
22
|
+
def min_batch_weight: (Binpacker::Timing, Hash[untyped, Float]) -> Float
|
|
23
|
+
def drain_batch: (Binpacker::WorkerQueue?, Float) -> Array[untyped]
|
|
24
|
+
def finalize: (Binpacker::Timing, Array[untyped], untyped, untyped, untyped, untyped) -> { passed: untyped, total: untyped, passed_count: untyped, timings: Array[untyped], empty_filter: untyped }
|
|
25
|
+
def minitest_empty_filter?: (untyped, untyped) -> bool
|
|
26
|
+
def minitest_include_filter?: () -> bool
|
|
5
27
|
end
|
|
6
28
|
end
|
data/sig/binpacker/timing.rbs
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
module Binpacker
|
|
2
2
|
class Timing
|
|
3
3
|
def initialize: (untyped) -> void
|
|
4
|
+
DEFAULT_WEIGHT: Float
|
|
5
|
+
MAX_SAMPLES_PER_TEST: Integer
|
|
6
|
+
def calibrated?: () -> bool
|
|
7
|
+
def load_with_fallback: (untyped) -> Hash[untyped, Float]
|
|
4
8
|
def load_raw: () -> (Hash[[String, untyped], untyped] | {})
|
|
5
9
|
def load_per_file: () -> (Hash[String, untyped] | {})
|
|
6
10
|
def normalize_path: (untyped) -> String
|
|
7
11
|
def measured?: (file: untyped, name: untyped) -> bool
|
|
12
|
+
def compact!: () -> nil
|
|
13
|
+
def append_all: (untyped) -> nil
|
|
8
14
|
class Entry
|
|
9
15
|
end
|
|
10
16
|
end
|