corkscrews 0.1.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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +130 -0
  4. data/Rakefile +20 -0
  5. data/exe/corkscrews +9 -0
  6. data/ext/corkscrews/corkscrews.c +24 -0
  7. data/ext/corkscrews/corkscrews_native.h +85 -0
  8. data/ext/corkscrews/delay.c +259 -0
  9. data/ext/corkscrews/extconf.rb +25 -0
  10. data/ext/corkscrews/hooks.c +156 -0
  11. data/ext/corkscrews/monitor.c +117 -0
  12. data/ext/corkscrews/record.c +109 -0
  13. data/ext/corkscrews/sampler.c +99 -0
  14. data/lib/corkscrews/analysis.rb +452 -0
  15. data/lib/corkscrews/boot.rb +25 -0
  16. data/lib/corkscrews/cli.rb +159 -0
  17. data/lib/corkscrews/controller.rb +137 -0
  18. data/lib/corkscrews/firefox_profile.rb +290 -0
  19. data/lib/corkscrews/native.rb +108 -0
  20. data/lib/corkscrews/primitives.rb +127 -0
  21. data/lib/corkscrews/rack.rb +39 -0
  22. data/lib/corkscrews/recorder.rb +903 -0
  23. data/lib/corkscrews/report.rb +241 -0
  24. data/lib/corkscrews/statistics.rb +112 -0
  25. data/lib/corkscrews/time_source.rb +11 -0
  26. data/lib/corkscrews/validate.rb +348 -0
  27. data/lib/corkscrews/version.rb +5 -0
  28. data/lib/corkscrews.rb +43 -0
  29. data/validate/benchmarks/b01_serial_hotspot/base.rb +28 -0
  30. data/validate/benchmarks/b01_serial_hotspot/manifest.yml +23 -0
  31. data/validate/benchmarks/b01_serial_hotspot/optimized.rb +28 -0
  32. data/validate/benchmarks/b02_false_hotspot/base.rb +43 -0
  33. data/validate/benchmarks/b02_false_hotspot/manifest.yml +19 -0
  34. data/validate/benchmarks/b02_false_hotspot/optimized.rb +43 -0
  35. data/validate/benchmarks/b03_io_wait/base.rb +29 -0
  36. data/validate/benchmarks/b03_io_wait/manifest.yml +16 -0
  37. data/validate/benchmarks/b03_io_wait/optimized.rb +29 -0
  38. data/validate/benchmarks/b04_lock_contention/base.rb +30 -0
  39. data/validate/benchmarks/b04_lock_contention/manifest.yml +19 -0
  40. data/validate/benchmarks/b04_lock_contention/optimized.rb +30 -0
  41. data/validate/benchmarks/b05_gvl_contention/base.rb +24 -0
  42. data/validate/benchmarks/b05_gvl_contention/manifest.yml +17 -0
  43. data/validate/benchmarks/b05_gvl_contention/optimized.rb +24 -0
  44. data/validate/benchmarks/b06_gc_pressure/base.rb +16 -0
  45. data/validate/benchmarks/b06_gc_pressure/manifest.yml +16 -0
  46. data/validate/benchmarks/b06_gc_pressure/optimized.rb +17 -0
  47. data/validate/benchmarks/b07_pipeline/base.rb +34 -0
  48. data/validate/benchmarks/b07_pipeline/manifest.yml +17 -0
  49. data/validate/benchmarks/b07_pipeline/optimized.rb +34 -0
  50. data/validate/benchmarks/b08_native_attribution/base.rb +18 -0
  51. data/validate/benchmarks/b08_native_attribution/manifest.yml +18 -0
  52. data/validate/benchmarks/b08_native_attribution/optimized.rb +18 -0
  53. data/validate/benchmarks/support.rb +29 -0
  54. data/validate/harness/run_all.rb +16 -0
  55. data/validate/harness/stats_check.py +27 -0
  56. data/validate/harness/t1.rb +15 -0
  57. data/validate/harness/t4.rb +15 -0
  58. data/validate/harness/t5.rb +15 -0
  59. metadata +118 -0
@@ -0,0 +1,452 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "statistics"
6
+
7
+ module Corkscrews
8
+ class Analysis
9
+ SPEEDUPS = (0..95).step(5).to_a.freeze
10
+
11
+ Run = Struct.new(:pid, :run_id, :repeat_index, :duration_ns, :samples, :waits, :progress, :latency, :rounds, :native, :runtime, keyword_init: true)
12
+
13
+ class << self
14
+ def load(path)
15
+ new(parse_events(path))
16
+ end
17
+
18
+ private
19
+
20
+ def parse_events(path)
21
+ File.readlines(path, chomp: true).filter_map do |line|
22
+ next if line.strip.empty?
23
+
24
+ JSON.parse(line)
25
+ end
26
+ end
27
+ end
28
+
29
+ def initialize(events)
30
+ @events = events
31
+ @runs = build_runs(events)
32
+ end
33
+
34
+ attr_reader :events, :runs
35
+
36
+ def empty?
37
+ @runs.empty?
38
+ end
39
+
40
+ def aggregate
41
+ {
42
+ run_count: @runs.length,
43
+ duration_ns: @runs.sum(&:duration_ns),
44
+ total_samples: @runs.sum { |run| run.samples.values.sum { |value| value[:samples] } },
45
+ progress: aggregate_progress,
46
+ latency: aggregate_latency,
47
+ native: aggregate_native,
48
+ runtime: aggregate_runtime,
49
+ rounds: aggregate_rounds,
50
+ targets: aggregate_targets
51
+ }
52
+ end
53
+
54
+ def target_curve(file: nil, line: nil, kind: "line", name: nil)
55
+ target = aggregate_targets.find do |candidate|
56
+ if kind.to_s == "wait"
57
+ candidate[:kind] == "wait" && candidate[:name] == name.to_s
58
+ else
59
+ candidate[:kind] == "line" &&
60
+ File.expand_path(candidate[:file]) == File.expand_path(file) &&
61
+ candidate[:line].to_i == line.to_i
62
+ end
63
+ end
64
+
65
+ target&.fetch(:curve)
66
+ end
67
+
68
+ private
69
+
70
+ def build_runs(events)
71
+ grouped = Hash.new do |hash, key|
72
+ hash[key] = {
73
+ samples: Hash.new(0),
74
+ causal_samples: Hash.new(0),
75
+ waits: Hash.new { |wait_hash, wait_key| wait_hash[wait_key] = { count: 0, duration_ns: 0 } },
76
+ progress: {},
77
+ latency: {},
78
+ rounds: [],
79
+ native: {},
80
+ runtime: {},
81
+ native_samples: []
82
+ }
83
+ end
84
+
85
+ events.each do |event|
86
+ key = [event["pid"], event["run_id"], event["repeat_index"]]
87
+ bucket = grouped[key]
88
+
89
+ case event["type"]
90
+ when "process"
91
+ bucket[:duration_ns] = event.fetch("duration_ns")
92
+ when "progress"
93
+ bucket[:progress][event.fetch("name")] = {
94
+ count: event.fetch("count"),
95
+ observed_ns: event.fetch("observed_ns")
96
+ }
97
+ when "latency"
98
+ bucket[:latency][event.fetch("name")] = {
99
+ begin_count: event.fetch("begin_count"),
100
+ end_count: event.fetch("end_count"),
101
+ completed_count: event.fetch("completed_count", 0),
102
+ total_duration_ns: event.fetch("total_duration_ns", 0),
103
+ mean_duration_ns: event.fetch("mean_duration_ns", 0),
104
+ little_mean_duration_ns: event.fetch("little_mean_duration_ns", event.fetch("mean_duration_ns", 0)),
105
+ inflight_area_ns: event.fetch("inflight_area_ns", 0)
106
+ }
107
+ when "line"
108
+ target = event.fetch("target")
109
+ key = [target.fetch("file"), target.fetch("line")]
110
+ bucket[:samples][key] += event.fetch("samples")
111
+ bucket[:causal_samples][key] += event.fetch("causal_samples", event.fetch("samples"))
112
+ when "wait"
113
+ target = event.fetch("target")
114
+ wait = bucket[:waits][target.fetch("name")]
115
+ wait[:count] += event.fetch("count")
116
+ wait[:duration_ns] += event.fetch("duration_ns")
117
+ when "round"
118
+ bucket[:rounds] << event
119
+ when "native"
120
+ bucket[:native] = event
121
+ when "native_sample"
122
+ bucket[:native_samples] << event
123
+ when "runtime"
124
+ bucket[:runtime] = event
125
+ end
126
+ end
127
+
128
+ grouped.map do |(pid, run_id, repeat_index), bucket|
129
+ Run.new(
130
+ pid: pid,
131
+ run_id: run_id,
132
+ repeat_index: repeat_index,
133
+ duration_ns: bucket[:duration_ns].to_i,
134
+ samples: merge_sample_counts(bucket[:samples], bucket[:causal_samples]),
135
+ waits: bucket[:waits],
136
+ progress: bucket[:progress],
137
+ latency: bucket[:latency],
138
+ rounds: bucket[:rounds],
139
+ native: merge_native(bucket[:native], bucket[:native_samples]),
140
+ runtime: bucket[:runtime]
141
+ )
142
+ end
143
+ end
144
+
145
+ def merge_native(native, native_samples)
146
+ return native if native_samples.empty?
147
+
148
+ snapshot = native.fetch("snapshot", {}).dup
149
+ snapshot["ring_events"] = native_samples.length
150
+ snapshot["ring_target_hits"] = native_samples.count { |entry| entry["target_hit"] }
151
+ native.merge("snapshot" => snapshot, "samples" => native_samples)
152
+ end
153
+
154
+ def merge_sample_counts(samples, causal_samples)
155
+ samples.to_h do |key, count|
156
+ [key, { samples: count, causal_samples: causal_samples.fetch(key, count) }]
157
+ end
158
+ end
159
+
160
+ def aggregate_progress
161
+ names = @runs.flat_map { |run| run.progress.keys }.uniq
162
+
163
+ names.to_h do |name|
164
+ rates = @runs.filter_map do |run|
165
+ progress = run.progress[name]
166
+ next unless progress
167
+
168
+ ns = progress[:observed_ns].positive? ? progress[:observed_ns] : run.duration_ns
169
+ progress[:count].to_f / (ns.to_f / 1_000_000_000)
170
+ end
171
+
172
+ [name, {
173
+ mean_rate: Statistics.mean(rates),
174
+ rate_ci: Statistics.bootstrap_mean_ci(rates),
175
+ count: @runs.sum { |run| run.progress.dig(name, :count).to_i }
176
+ }]
177
+ end
178
+ end
179
+
180
+ def aggregate_targets
181
+ all_sites = @runs.flat_map { |run| run.samples.keys }.uniq
182
+
183
+ # Paper basis: Coz SOSP'15 Section 2 "Producing a causal profile"
184
+ # groups experiments by the optimized line and plots program speedup
185
+ # against virtual speedup.
186
+ # Paper URL: https://arxiv.org/abs/1608.03676
187
+ line_targets = all_sites.map do |file, line|
188
+ shares = @runs.map do |run|
189
+ total = run.samples.values.sum { |value| value[:samples] }
190
+ total.positive? ? capped_share(run.samples.dig([file, line], :samples).to_f / total) : 0.0
191
+ end
192
+ causal_shares = @runs.map do |run|
193
+ total = run.samples.values.sum { |value| value[:samples] }
194
+ total.positive? ? capped_share(run.samples.dig([file, line], :causal_samples).to_f / total) : 0.0
195
+ end
196
+ mean_share = Statistics.mean(shares)
197
+ share_ci = Statistics.bootstrap_mean_ci(shares)
198
+ causal_curve = curve_for(causal_shares)
199
+
200
+ {
201
+ kind: "line",
202
+ file: file,
203
+ line: line,
204
+ samples: @runs.sum { |run| run.samples.dig([file, line], :samples).to_i },
205
+ causal_samples: @runs.sum { |run| run.samples.dig([file, line], :causal_samples).to_i },
206
+ sample_share: mean_share,
207
+ sample_share_ci: share_ci,
208
+ causal_share: Statistics.mean(causal_shares),
209
+ curve: causal_curve,
210
+ profile_curve: curve_for(shares),
211
+ round_curve: round_curve_for_target(kind: "line", file: file, line: line),
212
+ curve_source: "causal_share",
213
+ verdict: verdict_for(causal_curve)
214
+ }
215
+ end
216
+
217
+ wait_targets = aggregate_wait_targets
218
+ (line_targets + wait_targets).sort_by { |target| -target[:sample_share] }
219
+ end
220
+
221
+ def aggregate_wait_targets
222
+ kinds = @runs.flat_map { |run| run.waits.keys }.uniq
223
+
224
+ # Paper basis: Ousterhout et al. NSDI'15 Section 2.3.1 measures
225
+ # blocked time from the compute thread's perspective and Section
226
+ # 2.3.2 treats subtracting blocked time as an upper-bound simulation.
227
+ # Paper URL: https://www.usenix.org/system/files/conference/nsdi15/nsdi15-paper-ousterhout.pdf
228
+ kinds.map do |kind|
229
+ shares = @runs.map do |run|
230
+ next 0.0 unless run.duration_ns.positive?
231
+
232
+ capped_share(run.waits.dig(kind, :duration_ns).to_f / run.duration_ns)
233
+ end
234
+ curve = curve_for(shares)
235
+
236
+ {
237
+ kind: "wait",
238
+ name: kind,
239
+ samples: @runs.sum { |run| run.waits.dig(kind, :count).to_i },
240
+ sample_share: Statistics.mean(shares),
241
+ sample_share_ci: Statistics.bootstrap_mean_ci(shares),
242
+ causal_share: Statistics.mean(shares),
243
+ duration_ns: @runs.sum { |run| run.waits.dig(kind, :duration_ns).to_i },
244
+ curve: curve,
245
+ round_curve: round_curve_for_target(kind: "wait", name: kind),
246
+ curve_source: "causal_share",
247
+ verdict: verdict_for(curve)
248
+ }
249
+ end
250
+ end
251
+
252
+ def aggregate_latency
253
+ names = @runs.flat_map { |run| run.latency.keys }.uniq
254
+
255
+ # Paper basis: Coz SOSP'15 Section 3.3 "Measuring latency" uses
256
+ # Little's Law, L = lambda W, for paired progress points.
257
+ # Paper URL: https://arxiv.org/abs/1608.03676
258
+ names.to_h do |name|
259
+ means = @runs.filter_map do |run|
260
+ latency = run.latency[name]
261
+ next unless latency
262
+
263
+ latency[:mean_duration_ns].to_f / 1_000_000
264
+ end
265
+ little_means = @runs.filter_map do |run|
266
+ latency = run.latency[name]
267
+ next unless latency
268
+
269
+ latency[:little_mean_duration_ns].to_f / 1_000_000
270
+ end
271
+ [name, {
272
+ mean_ms: Statistics.mean(means),
273
+ mean_ms_ci: Statistics.bootstrap_mean_ci(means),
274
+ little_mean_ms: Statistics.mean(little_means),
275
+ little_mean_ms_ci: Statistics.bootstrap_mean_ci(little_means),
276
+ completed_count: @runs.sum { |run| run.latency.dig(name, :completed_count).to_i }
277
+ }]
278
+ end
279
+ end
280
+
281
+ def aggregate_native
282
+ snapshots = @runs.map { |run| run.native.fetch("snapshot", {}) if run.native }.compact
283
+ keys = snapshots.flat_map(&:keys).uniq
284
+ keys.to_h do |key|
285
+ values = snapshots.map { |snapshot| snapshot[key] }
286
+ [key.to_sym, aggregate_native_value(key, values)]
287
+ end
288
+ end
289
+
290
+ def aggregate_native_value(key, values)
291
+ return values.any? if values.any? { |value| value == true || value == false }
292
+ return values.map(&:to_i).max if native_max_key?(key)
293
+
294
+ values.sum { |value| value.to_i }
295
+ end
296
+
297
+ def native_max_key?(key)
298
+ key.to_s.start_with?("thread_state_") || %w[
299
+ thread_live_count
300
+ thread_max_live_count
301
+ record_ring_size
302
+ ].include?(key.to_s)
303
+ end
304
+
305
+ def aggregate_runtime
306
+ {
307
+ fiber_switches: @runs.sum { |run| run.runtime.fetch("fiber_switches", 0).to_i },
308
+ max_fiber_count: @runs.map { |run| run.runtime.fetch("fiber_count", 0).to_i }.max || 0,
309
+ fiber_thread_count: @runs.map { |run| run.runtime.fetch("fiber_thread_count", 0).to_i }.max || 0,
310
+ fiber_scheduler: @runs.any? { |run| run.runtime.fetch("fiber_scheduler", false) },
311
+ max_ractor_count: @runs.map { |run| run.runtime.fetch("ractor_count", 1).to_i }.max || 1
312
+ }
313
+ end
314
+
315
+ def aggregate_rounds
316
+ @runs.flat_map(&:rounds)
317
+ end
318
+
319
+ def curve_for(shares)
320
+ points = SPEEDUPS.map do |speedup_pct|
321
+ improvements = shares.map { |share| improvement_pct(share, speedup_pct) }
322
+ ci = Statistics.bootstrap_mean_ci(improvements)
323
+
324
+ {
325
+ speedup_pct: speedup_pct,
326
+ improvement_pct: Statistics.mean(improvements),
327
+ ci_low: ci[0],
328
+ ci_high: ci[1]
329
+ }
330
+ end
331
+
332
+ Statistics.monotonic_regression(points)
333
+ end
334
+
335
+ def round_curve_for_target(kind:, file: nil, line: nil, name: nil)
336
+ target_rounds = matching_rounds(kind: kind, file: file, line: line, name: name)
337
+ return nil if target_rounds.empty?
338
+
339
+ # Paper basis: Coz SOSP'15 Section 2 requires a 0% baseline per
340
+ # target, then computes program speedup relative to that baseline.
341
+ # Paper URL: https://arxiv.org/abs/1608.03676
342
+ baseline_rates = target_rounds
343
+ .select { |round| round.fetch("baseline", false) || round.fetch("speedup_pct").to_i.zero? }
344
+ .filter_map { |round| round_rate(round) }
345
+ return nil if baseline_rates.empty?
346
+
347
+ baseline = Statistics.median(baseline_rates)
348
+ return nil unless baseline.positive?
349
+
350
+ observed_by_speedup = target_rounds.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |round, hash|
351
+ speedup_pct = round.fetch("speedup_pct").to_i
352
+ rate = round_rate(round)
353
+ next unless rate
354
+
355
+ hash[speedup_pct] << ((rate / baseline) - 1.0) * 100.0
356
+ end
357
+ return nil unless round_curve_covered?(observed_by_speedup)
358
+
359
+ points = SPEEDUPS.map do |speedup_pct|
360
+ improvements = observed_by_speedup.fetch(speedup_pct, [])
361
+
362
+ if improvements.empty?
363
+ interpolated = interpolated_round_improvement(observed_by_speedup, speedup_pct)
364
+ { speedup_pct: speedup_pct, improvement_pct: interpolated, ci_low: interpolated, ci_high: interpolated }
365
+ else
366
+ ci = Statistics.bootstrap_mean_ci(improvements)
367
+ {
368
+ speedup_pct: speedup_pct,
369
+ improvement_pct: Statistics.mean(improvements),
370
+ ci_low: ci[0],
371
+ ci_high: ci[1]
372
+ }
373
+ end
374
+ end
375
+
376
+ Statistics.monotonic_regression(points)
377
+ end
378
+
379
+ def round_curve_covered?(observed_by_speedup)
380
+ return false if observed_by_speedup.fetch(0, []).length < 2
381
+
382
+ major_speedups = [25, 50, 90]
383
+ observed_nonzero = observed_by_speedup.keys.reject(&:zero?)
384
+ return true if major_speedups.all? { |speedup| observed_by_speedup.key?(speedup) }
385
+
386
+ observed_nonzero.length >= 6
387
+ end
388
+
389
+ def interpolated_round_improvement(observed_by_speedup, speedup_pct)
390
+ return Statistics.mean(observed_by_speedup.fetch(speedup_pct)) if observed_by_speedup.key?(speedup_pct)
391
+
392
+ observed = observed_by_speedup.keys.sort
393
+ lower = observed.select { |candidate| candidate < speedup_pct }.max
394
+ upper = observed.select { |candidate| candidate > speedup_pct }.min
395
+ return 0.0 unless lower || upper
396
+ return Statistics.mean(observed_by_speedup.fetch(upper)) unless lower
397
+ return Statistics.mean(observed_by_speedup.fetch(lower)) unless upper
398
+
399
+ lower_value = Statistics.mean(observed_by_speedup.fetch(lower))
400
+ upper_value = Statistics.mean(observed_by_speedup.fetch(upper))
401
+ span = upper - lower
402
+ return lower_value if span.zero?
403
+
404
+ lower_value + ((upper_value - lower_value) * ((speedup_pct - lower).to_f / span))
405
+ end
406
+
407
+ def matching_rounds(kind:, file: nil, line: nil, name: nil)
408
+ aggregate_rounds.select do |round|
409
+ target = round.fetch("target", {})
410
+ if kind.to_s == "wait"
411
+ target.fetch("kind", nil) == "wait" && target.fetch("name", nil).to_s == name.to_s
412
+ else
413
+ target.fetch("kind", nil) == "line" &&
414
+ File.expand_path(target.fetch("file", "")) == File.expand_path(file) &&
415
+ target.fetch("line", nil).to_i == line.to_i
416
+ end
417
+ end
418
+ end
419
+
420
+ def round_rate(round)
421
+ visits = round.fetch("visits", 0).to_f
422
+ duration_ns = round.fetch("virtual_duration_ns", round.fetch("physical_duration_ns", 0)).to_f
423
+ return nil unless visits.positive? && duration_ns.positive?
424
+
425
+ visits / (duration_ns / 1_000_000_000.0)
426
+ end
427
+
428
+ def improvement_pct(share, speedup_pct)
429
+ # Paper basis: Coz SOSP'15 Section 2 / Figure 3 virtual speedup:
430
+ # reducing a fraction of execution by s predicts 1 / (1 - share*s).
431
+ # Paper URL: https://arxiv.org/abs/1608.03676
432
+ reduced_fraction = share.to_f * (speedup_pct.to_f / 100.0)
433
+ return 0.0 if reduced_fraction <= 0.0
434
+ return Float::INFINITY if reduced_fraction >= 1.0
435
+
436
+ ((1.0 / (1.0 - reduced_fraction)) - 1.0) * 100.0
437
+ end
438
+
439
+ def capped_share(value)
440
+ [[value.to_f, 0.0].max, 0.95].min
441
+ end
442
+
443
+ def verdict_for(curve)
444
+ point = curve.find { |entry| entry[:speedup_pct] == 25 }
445
+ return "insufficient" unless point
446
+ return "bottleneck" if point[:ci_low].positive?
447
+ return "negative" if point[:ci_high].negative?
448
+
449
+ "unknown"
450
+ end
451
+ end
452
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../corkscrews"
4
+ require_relative "native"
5
+
6
+ if ENV["CORKSCREWS_PROFILE"] == "1"
7
+ Corkscrews::Native.install_hooks!
8
+
9
+ if ENV["CORKSCREWS_PRIMITIVES"] == "1"
10
+ require_relative "primitives"
11
+ Corkscrews::Primitives.install!
12
+ end
13
+
14
+ output = ENV.fetch("CORKSCREWS_OUTPUT")
15
+ Corkscrews.start!(
16
+ output: output,
17
+ run_id: ENV["CORKSCREWS_RUN_ID"],
18
+ repeat_index: ENV["CORKSCREWS_REPEAT_INDEX"],
19
+ sample_period_ms: ENV["CORKSCREWS_SAMPLE_PERIOD_MS"]
20
+ )
21
+
22
+ at_exit do
23
+ Corkscrews.stop!
24
+ end
25
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "optparse"
6
+ require "securerandom"
7
+
8
+ require_relative "../corkscrews"
9
+ require_relative "report"
10
+ require_relative "validate"
11
+
12
+ module Corkscrews
13
+ class CLI
14
+ def initialize(argv)
15
+ @argv = argv.dup
16
+ end
17
+
18
+ def run
19
+ command = @argv.shift
20
+
21
+ case command
22
+ when "run"
23
+ run_profile(@argv)
24
+ when "report"
25
+ run_report(@argv)
26
+ when "validate"
27
+ run_validate(@argv)
28
+ when "-h", "--help", nil
29
+ puts help
30
+ 0
31
+ else
32
+ warn "unknown command: #{command}"
33
+ warn help
34
+ 2
35
+ end
36
+ rescue Error => e
37
+ warn "error: #{e.message}"
38
+ 1
39
+ end
40
+
41
+ private
42
+
43
+ def run_profile(argv)
44
+ options = {
45
+ repeat: 1,
46
+ output: "run.corkscrews.ndjson",
47
+ sample_period_ms: 1.0,
48
+ targets: "lines",
49
+ max_slowdown: nil
50
+ }
51
+
52
+ parser = OptionParser.new do |opts|
53
+ opts.banner = "usage: corkscrews run [options] -- ruby script.rb"
54
+ opts.on("--repeat N", Integer, "number of process repetitions") { |value| options[:repeat] = value }
55
+ opts.on("--duration SEC", Float, "advisory duration forwarded to benchmarks") { |value| options[:duration] = value }
56
+ opts.on("--progress NAME", "expected progress point name") { |value| options[:progress] = value }
57
+ opts.on("--targets KIND", "lines, waits, or both") { |value| options[:targets] = value }
58
+ opts.on("--output PATH", "NDJSON output path") { |value| options[:output] = value }
59
+ opts.on("--sample-period-ms MS", Float, "Ruby sampler period") { |value| options[:sample_period_ms] = value }
60
+ opts.on("--max-slowdown PCT", Float, "advisory slowdown budget recorded for reports") { |value| options[:max_slowdown] = value }
61
+ end
62
+
63
+ command = parse_command_after_separator(parser, argv)
64
+ raise Error, "missing command after --" if command.empty?
65
+
66
+ output = File.expand_path(options[:output])
67
+ FileUtils.rm_f(output)
68
+ run_id = SecureRandom.hex(8)
69
+
70
+ options[:repeat].times do |index|
71
+ env = profile_environment(output, run_id, index, options)
72
+ ok = system(env, *command)
73
+ raise Error, "profiled command failed on repeat #{index + 1}: #{command.join(" ")}" unless ok
74
+ end
75
+
76
+ puts output
77
+ 0
78
+ end
79
+
80
+ def run_report(argv)
81
+ options = { html: nil, firefox: nil, limit: 10 }
82
+ parser = OptionParser.new do |opts|
83
+ opts.banner = "usage: corkscrews report [options] run.corkscrews.ndjson"
84
+ opts.on("--html PATH", "write an HTML report") { |value| options[:html] = value }
85
+ opts.on("--firefox PATH", "write a Firefox Profiler compatible aggregate JSON") { |value| options[:firefox] = value }
86
+ opts.on("--limit N", Integer, "number of line targets to show") { |value| options[:limit] = value }
87
+ end
88
+ parser.parse!(argv)
89
+
90
+ path = argv.shift
91
+ raise Error, "missing NDJSON path" unless path
92
+
93
+ report = Report.new(path)
94
+ puts report.to_text(limit: options[:limit])
95
+ report.write_html(options[:html], limit: options[:limit]) if options[:html]
96
+ report.write_firefox(options[:firefox]) if options[:firefox]
97
+ 0
98
+ end
99
+
100
+ def run_validate(argv)
101
+ options = { quick: false, benchmark: nil }
102
+ parser = OptionParser.new do |opts|
103
+ opts.banner = "usage: corkscrews validate [options]"
104
+ opts.on("--quick", "use short validation settings") { options[:quick] = true }
105
+ opts.on("--benchmark ID", "run one benchmark") { |value| options[:benchmark] = value }
106
+ end
107
+ parser.parse!(argv)
108
+
109
+ result = Validate.run_all(quick: options[:quick], benchmark: options[:benchmark])
110
+ puts JSON.pretty_generate(result.to_h)
111
+ result.ok? ? 0 : 1
112
+ end
113
+
114
+ def parse_command_after_separator(parser, argv)
115
+ separator_index = argv.index("--")
116
+ raise Error, "missing -- separator" unless separator_index
117
+
118
+ option_args = argv.take(separator_index)
119
+ command = argv.drop(separator_index + 1)
120
+ parser.parse!(option_args)
121
+ command
122
+ end
123
+
124
+ def profile_environment(output, run_id, index, options)
125
+ lib_path = File.expand_path("..", __dir__)
126
+ ext_path = File.expand_path("../../ext/corkscrews", __dir__)
127
+ ruby_lib = [lib_path, ext_path, ENV["RUBYLIB"]].compact.reject(&:empty?).join(File::PATH_SEPARATOR)
128
+ ruby_opt = ["-rcorkscrews/boot", ENV["RUBYOPT"]].compact.reject(&:empty?).join(" ")
129
+
130
+ {
131
+ "RUBYLIB" => ruby_lib,
132
+ "RUBYOPT" => ruby_opt,
133
+ "CORKSCREWS_PROFILE" => "1",
134
+ "CORKSCREWS_OUTPUT" => output,
135
+ "CORKSCREWS_RUN_ID" => run_id,
136
+ "CORKSCREWS_REPEAT_INDEX" => index.to_s,
137
+ "CORKSCREWS_SAMPLE_PERIOD_MS" => options[:sample_period_ms].to_s,
138
+ "CORKSCREWS_TARGETS" => options[:targets].to_s,
139
+ "CORKSCREWS_PRIMITIVES" => primitive_instrumentation?(options).to_s,
140
+ "CORKSCREWS_MAX_SLOWDOWN" => options[:max_slowdown].to_s,
141
+ "CORKSCREWS_PROGRESS" => options[:progress].to_s,
142
+ "CS_BENCH_DURATION" => options[:duration].to_s
143
+ }
144
+ end
145
+
146
+ def primitive_instrumentation?(options)
147
+ %w[waits both].include?(options[:targets].to_s) ? "1" : "0"
148
+ end
149
+
150
+ def help
151
+ <<~TEXT
152
+ usage:
153
+ corkscrews run [options] -- ruby script.rb
154
+ corkscrews report [options] run.corkscrews.ndjson
155
+ corkscrews validate [options]
156
+ TEXT
157
+ end
158
+ end
159
+ end