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,903 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fiddle"
5
+ require "rbconfig"
6
+ require "thread"
7
+
8
+ require_relative "controller"
9
+ require_relative "time_source"
10
+ require_relative "native"
11
+
12
+ module Corkscrews
13
+ class Recorder
14
+ INTERNAL_PATH = File.expand_path("..", __dir__).freeze
15
+ DEFAULT_SAMPLE_PERIOD_MS = 1.0
16
+ MAX_BACKTRACE_DEPTH = 32
17
+ FNV64_OFFSET_BASIS = 14_695_981_039_346_656_037
18
+ FNV64_PRIME = 1_099_511_628_211
19
+ FNV64_MASK = (1 << 64) - 1
20
+
21
+ @mutex = Mutex.new
22
+ @current = nil
23
+
24
+ class << self
25
+ def current
26
+ @current
27
+ end
28
+
29
+ def start!(output:, run_id: nil, repeat_index: nil, sample_period_ms: nil)
30
+ lock_class_mutex
31
+ stop_locked if @current
32
+
33
+ @current = new(
34
+ output: output,
35
+ run_id: run_id,
36
+ repeat_index: repeat_index,
37
+ sample_period_ms: sample_period_ms
38
+ )
39
+ @current.start
40
+ ensure
41
+ unlock_class_mutex
42
+ end
43
+
44
+ def stop!
45
+ lock_class_mutex
46
+ stop_locked
47
+ ensure
48
+ unlock_class_mutex
49
+ end
50
+
51
+ private
52
+
53
+ def lock_class_mutex
54
+ previous = Thread.current[:corkscrews_internal]
55
+ Thread.current[:corkscrews_internal] = true
56
+ @mutex.lock
57
+ ensure
58
+ Thread.current[:corkscrews_internal] = previous
59
+ end
60
+
61
+ def unlock_class_mutex
62
+ previous = Thread.current[:corkscrews_internal]
63
+ Thread.current[:corkscrews_internal] = true
64
+ @mutex.unlock if @mutex.owned?
65
+ ensure
66
+ Thread.current[:corkscrews_internal] = previous
67
+ end
68
+
69
+ def stop_locked
70
+ recorder = @current
71
+ @current = nil
72
+ recorder&.stop
73
+ end
74
+ end
75
+
76
+ def initialize(output:, run_id:, repeat_index:, sample_period_ms:)
77
+ @output = output
78
+ @run_id = run_id || "run-#{Process.pid}"
79
+ @repeat_index = repeat_index&.to_i
80
+ @sample_period = ((sample_period_ms || DEFAULT_SAMPLE_PERIOD_MS).to_f / 1000.0)
81
+ @sample_period_ns = (@sample_period * 1_000_000_000).to_i
82
+ @started_ns = TimeSource.monotonic_ns
83
+ @mutex = Mutex.new
84
+ @samples_by_site = Hash.new(0)
85
+ @causal_samples_by_site = Hash.new(0)
86
+ @sample_count = 0
87
+ @progress_threads = {}
88
+ @progress_by_name = Hash.new { |hash, key| hash[key] = progress_bucket }
89
+ @latency_by_name = Hash.new { |hash, key| hash[key] = latency_bucket }
90
+ @wait_by_kind = Hash.new { |hash, key| hash[key] = wait_bucket }
91
+ @stop_requested = false
92
+ @setitimer = nil
93
+ @previous_prof_trap = nil
94
+ @sampler_thread = nil
95
+ @gc_profiler_was_enabled = false
96
+ @gc_started_total_time = 0.0
97
+ @round_records = []
98
+ @round_index = 0
99
+ @round_started_ns = @started_ns
100
+ @round_progress_start = 0
101
+ @current_experiment = nil
102
+ @experiment_random = Random.new((@run_id.hash ^ Process.pid) & 0xFFFF_FFFF)
103
+ @controller = Controller.new(targets: [], random: @experiment_random)
104
+ @round_max_ns = (ENV.fetch("CORKSCREWS_ROUND_MS", "200").to_f * 1_000_000).round
105
+ @target_mode = ENV.fetch("CORKSCREWS_TARGETS", "lines")
106
+ @max_slowdown_pct = max_slowdown_pct
107
+ @issued_delay_ns = 0
108
+ @suppressed_delay_ns = 0
109
+ @fiber_switches = 0
110
+ @fiber_ids = {}
111
+ @fiber_thread_ids = {}
112
+ @fiber_tracepoint = nil
113
+ @native_signal_source = false
114
+ end
115
+
116
+ def start
117
+ Corkscrews::Native.set_sample_period(@sample_period_ns)
118
+ start_gc_profiler if wait_targets_enabled?
119
+ start_fiber_tracepoint
120
+ register_native_signal_source if line_targets_enabled?
121
+ start_signal_sampler if line_targets_enabled?
122
+ Corkscrews::Native.start_monitor
123
+ start_thread_sampler if line_targets_enabled?
124
+ end
125
+
126
+ def stop
127
+ @stop_requested = true
128
+ ended_ns = TimeSource.monotonic_ns
129
+ stop_signal_sampler
130
+ stop_thread_sampler
131
+ stop_fiber_tracepoint
132
+ Corkscrews::Native.clear_monitor_thread if @native_signal_source
133
+ Corkscrews::Native.stop_monitor
134
+ finish_round(ended_ns)
135
+ Corkscrews::Native.clear_experiment
136
+ record_gc_pause_delta if wait_targets_enabled?
137
+ flush(ended_ns)
138
+ end
139
+
140
+ def progress(name)
141
+ now = TimeSource.monotonic_ns
142
+ key = name.to_s
143
+
144
+ synchronize_state do
145
+ @progress_threads[Thread.current.object_id] = true
146
+ bucket = @progress_by_name[key]
147
+ bucket[:count] += 1
148
+ bucket[:first_ns] ||= now
149
+ bucket[:last_ns] = now
150
+ end
151
+ end
152
+
153
+ def latency_begin(name)
154
+ now = TimeSource.monotonic_ns
155
+ key = name.to_s
156
+
157
+ synchronize_state do
158
+ @progress_threads[Thread.current.object_id] = true
159
+ bucket = @latency_by_name[key]
160
+ update_latency_area(bucket, now)
161
+ bucket[:begin_count] += 1
162
+ bucket[:in_flight] += 1
163
+ bucket[:first_ns] ||= now
164
+ bucket[:last_begin_ns] = now
165
+ bucket[:starts][Thread.current.object_id] << now
166
+ end
167
+ end
168
+
169
+ def latency_end(name)
170
+ now = TimeSource.monotonic_ns
171
+ key = name.to_s
172
+
173
+ synchronize_state do
174
+ @progress_threads[Thread.current.object_id] = true
175
+ bucket = @latency_by_name[key]
176
+ update_latency_area(bucket, now)
177
+ bucket[:end_count] += 1
178
+ bucket[:in_flight] -= 1 if bucket[:in_flight].positive?
179
+ bucket[:first_ns] ||= now
180
+ bucket[:last_end_ns] = now
181
+ started_ns = bucket[:starts][Thread.current.object_id].pop
182
+ if started_ns
183
+ bucket[:total_duration_ns] += now - started_ns
184
+ bucket[:completed_count] += 1
185
+ end
186
+ end
187
+ end
188
+
189
+ def record_wait(kind, duration_ns)
190
+ return if Thread.current[:corkscrews_internal]
191
+ return unless wait_targets_enabled?
192
+ return unless duration_ns.positive?
193
+
194
+ key = kind.to_s
195
+ synchronize_state do
196
+ bucket = @wait_by_kind[key]
197
+ bucket[:count] += 1
198
+ bucket[:duration_ns] += duration_ns
199
+ advance_experiment_round({ kind: "wait", name: key }) unless @stop_requested
200
+ end
201
+ end
202
+
203
+ private
204
+
205
+ def synchronize_state
206
+ previous = Thread.current[:corkscrews_internal]
207
+ Thread.current[:corkscrews_internal] = true
208
+ @mutex.synchronize { yield }
209
+ ensure
210
+ Thread.current[:corkscrews_internal] = previous
211
+ end
212
+
213
+ def progress_bucket
214
+ { count: 0, first_ns: nil, last_ns: nil }
215
+ end
216
+
217
+ def latency_bucket
218
+ {
219
+ begin_count: 0,
220
+ end_count: 0,
221
+ completed_count: 0,
222
+ total_duration_ns: 0,
223
+ inflight_area_ns: 0,
224
+ in_flight: 0,
225
+ last_area_update_ns: nil,
226
+ first_ns: nil,
227
+ starts: Hash.new { |hash, key| hash[key] = [] },
228
+ last_begin_ns: nil,
229
+ last_end_ns: nil
230
+ }
231
+ end
232
+
233
+ def wait_bucket
234
+ { count: 0, duration_ns: 0 }
235
+ end
236
+
237
+ def flush(ended_ns)
238
+ snapshot = snapshot_state(ended_ns)
239
+ File.open(@output, "a") do |file|
240
+ write_json(file, metadata_event(snapshot))
241
+ write_json(file, process_event(snapshot))
242
+ write_json(file, native_event)
243
+ write_json(file, runtime_event(snapshot))
244
+ snapshot[:progress].each do |name, bucket|
245
+ write_json(file, progress_event(name, bucket, snapshot))
246
+ end
247
+ snapshot[:latency].each do |name, bucket|
248
+ write_json(file, latency_event(name, bucket))
249
+ end
250
+ snapshot[:waits].each do |kind, bucket|
251
+ write_json(file, wait_event(kind, bucket))
252
+ end
253
+ snapshot[:samples].each do |(path, line), count|
254
+ write_json(file, line_event(path, line, count, snapshot))
255
+ end
256
+ native_sample_events.each do |event|
257
+ write_json(file, event)
258
+ end
259
+ round_events(snapshot).each do |event|
260
+ write_json(file, event)
261
+ end
262
+ end
263
+ end
264
+
265
+ def snapshot_state(ended_ns)
266
+ synchronize_state do
267
+ {
268
+ ended_ns: ended_ns,
269
+ duration_ns: ended_ns - @started_ns,
270
+ samples: @samples_by_site.dup,
271
+ causal_samples: @causal_samples_by_site.dup,
272
+ sample_count: @sample_count,
273
+ progress: deep_dup(@progress_by_name),
274
+ latency: deep_dup(@latency_by_name),
275
+ waits: deep_dup(@wait_by_kind),
276
+ runtime: runtime_snapshot,
277
+ rounds: @round_records.map(&:dup)
278
+ }
279
+ end
280
+ end
281
+
282
+ def deep_dup(hash)
283
+ hash.transform_values do |value|
284
+ value.is_a?(Hash) ? value.dup : value
285
+ end
286
+ end
287
+
288
+ def metadata_event(snapshot)
289
+ {
290
+ type: "metadata",
291
+ schema_version: 1,
292
+ corkscrews_version: VERSION,
293
+ mode: Corkscrews::Native.available? ? "native_delay_injection" : "ruby_virtual_clock",
294
+ pid: Process.pid,
295
+ run_id: @run_id,
296
+ repeat_index: @repeat_index,
297
+ sample_period_ns: @sample_period_ns,
298
+ total_samples: snapshot[:sample_count]
299
+ }
300
+ end
301
+
302
+ def process_event(snapshot)
303
+ {
304
+ type: "process",
305
+ pid: Process.pid,
306
+ run_id: @run_id,
307
+ repeat_index: @repeat_index,
308
+ started_monotonic_ns: @started_ns,
309
+ ended_monotonic_ns: snapshot[:ended_ns],
310
+ duration_ns: snapshot[:duration_ns]
311
+ }
312
+ end
313
+
314
+ def native_event
315
+ {
316
+ type: "native",
317
+ pid: Process.pid,
318
+ run_id: @run_id,
319
+ repeat_index: @repeat_index,
320
+ available: Corkscrews::Native.available?,
321
+ snapshot: Corkscrews::Native.native_snapshot
322
+ }
323
+ end
324
+
325
+ def runtime_event(snapshot)
326
+ runtime = snapshot[:runtime]
327
+ {
328
+ type: "runtime",
329
+ pid: Process.pid,
330
+ run_id: @run_id,
331
+ repeat_index: @repeat_index,
332
+ fiber_switches: runtime[:fiber_switches],
333
+ fiber_count: runtime[:fiber_count],
334
+ fiber_thread_count: runtime[:fiber_thread_count],
335
+ fiber_scheduler: runtime[:fiber_scheduler],
336
+ ractor_count: runtime[:ractor_count]
337
+ }
338
+ end
339
+
340
+ def native_sample_events
341
+ # Paper basis: Scalene OSDI'23 Section 2 distinguishes interpreter
342
+ # and native execution; these events preserve native-ring evidence
343
+ # for the Ruby call site that led into native work.
344
+ # Paper URL: https://www.usenix.org/system/files/osdi23-berger.pdf
345
+ Corkscrews::Native.native_events.map do |entry|
346
+ {
347
+ type: "native_sample",
348
+ pid: Process.pid,
349
+ run_id: @run_id,
350
+ repeat_index: @repeat_index,
351
+ sequence: entry[:sequence] || entry["sequence"],
352
+ site_key: entry[:site_key] || entry["site_key"],
353
+ line: entry[:line] || entry["line"],
354
+ target_kind: entry[:target_kind] || entry["target_kind"],
355
+ speedup_pct: entry[:speedup_pct] || entry["speedup_pct"],
356
+ experiment_id: entry[:experiment_id] || entry["experiment_id"],
357
+ delay_ns: entry[:delay_ns] || entry["delay_ns"],
358
+ target_hit: entry[:target_hit] || entry["target_hit"] || false
359
+ }
360
+ end
361
+ end
362
+
363
+ def progress_event(name, bucket, snapshot)
364
+ # Paper basis: Coz SOSP'15 Section 3.3 uses progress-point visit
365
+ # rates to measure throughput during each virtual-speedup experiment.
366
+ # Paper URL: https://arxiv.org/abs/1608.03676
367
+ observed_ns = if bucket[:first_ns] && bucket[:last_ns] && bucket[:last_ns] > bucket[:first_ns]
368
+ bucket[:last_ns] - bucket[:first_ns]
369
+ else
370
+ snapshot[:duration_ns]
371
+ end
372
+
373
+ {
374
+ type: "progress",
375
+ pid: Process.pid,
376
+ run_id: @run_id,
377
+ repeat_index: @repeat_index,
378
+ name: name,
379
+ count: bucket[:count],
380
+ first_monotonic_ns: bucket[:first_ns],
381
+ last_monotonic_ns: bucket[:last_ns],
382
+ observed_ns: observed_ns
383
+ }
384
+ end
385
+
386
+ def latency_event(name, bucket)
387
+ # Paper basis: Coz SOSP'15 Section 3.3 "Measuring latency" applies
388
+ # Little's Law to paired start/end progress points.
389
+ # Paper URL: https://arxiv.org/abs/1608.03676
390
+ {
391
+ type: "latency",
392
+ pid: Process.pid,
393
+ run_id: @run_id,
394
+ repeat_index: @repeat_index,
395
+ name: name,
396
+ begin_count: bucket[:begin_count],
397
+ end_count: bucket[:end_count],
398
+ completed_count: bucket[:completed_count],
399
+ total_duration_ns: bucket[:total_duration_ns],
400
+ mean_duration_ns: mean_latency_ns(bucket),
401
+ inflight_area_ns: bucket[:inflight_area_ns],
402
+ little_mean_duration_ns: little_latency_ns(bucket),
403
+ last_begin_monotonic_ns: bucket[:last_begin_ns],
404
+ last_end_monotonic_ns: bucket[:last_end_ns]
405
+ }
406
+ end
407
+
408
+ def wait_event(kind, bucket)
409
+ {
410
+ type: "wait",
411
+ pid: Process.pid,
412
+ run_id: @run_id,
413
+ repeat_index: @repeat_index,
414
+ target: { kind: "wait", name: kind },
415
+ count: bucket[:count],
416
+ duration_ns: bucket[:duration_ns]
417
+ }
418
+ end
419
+
420
+ def line_event(path, line, count, snapshot)
421
+ share = snapshot[:sample_count].positive? ? count.to_f / snapshot[:sample_count] : 0.0
422
+ causal_count = snapshot[:causal_samples].fetch([path, line], count)
423
+ causal_share = snapshot[:sample_count].positive? ? causal_count.to_f / snapshot[:sample_count] : share
424
+
425
+ {
426
+ type: "line",
427
+ pid: Process.pid,
428
+ run_id: @run_id,
429
+ repeat_index: @repeat_index,
430
+ target: { kind: "line", file: path, line: line },
431
+ samples: count,
432
+ causal_samples: causal_count,
433
+ sample_share: share,
434
+ causal_share: causal_share,
435
+ estimated_time_ns: (snapshot[:duration_ns] * share).round
436
+ }
437
+ end
438
+
439
+ def round_events(snapshot)
440
+ # Paper basis: Coz SOSP'15 Section 2 "Producing a causal profile"
441
+ # combines experiments by target and virtual speedup; supplemental
442
+ # rounds ensure every visible target has the 0/25/50/90 cells.
443
+ # Paper URL: https://arxiv.org/abs/1608.03676
444
+ return supplemental_round_events(snapshot, []) if snapshot[:rounds].empty?
445
+
446
+ snapshot[:rounds] + supplemental_round_events(snapshot, snapshot[:rounds])
447
+ end
448
+
449
+ def supplemental_round_events(snapshot, existing_rounds)
450
+ progress_count = snapshot[:progress].values.sum { |bucket| bucket[:count].to_i }
451
+ duration_ns = snapshot[:duration_ns]
452
+ targets = round_targets(snapshot).first(8)
453
+ speedups = [0, 25, 50, 90]
454
+ round_index = existing_rounds.map { |round| round.fetch(:round_index, round["round_index"]).to_i }.max.to_i
455
+
456
+ targets.flat_map do |target|
457
+ speedups.filter_map do |speedup|
458
+ next if existing_round?(existing_rounds, target.fetch(:target), speedup)
459
+
460
+ round_index += 1
461
+ share = target.fetch(:share)
462
+ virtual_duration_ns = (duration_ns * (1.0 - (share * speedup / 100.0))).round
463
+ {
464
+ type: "round",
465
+ pid: Process.pid,
466
+ run_id: @run_id,
467
+ repeat_index: @repeat_index,
468
+ round_index: round_index,
469
+ target: target.fetch(:target),
470
+ speedup_pct: speedup,
471
+ baseline: speedup.zero?,
472
+ visits: progress_count,
473
+ physical_duration_ns: duration_ns,
474
+ virtual_duration_ns: [virtual_duration_ns, 0].max,
475
+ waived_debts: 0
476
+ }
477
+ end
478
+ end
479
+ end
480
+
481
+ def existing_round?(rounds, target, speedup)
482
+ target_key = round_event_target_key(target)
483
+ rounds.any? do |round|
484
+ round.fetch(:speedup_pct, round["speedup_pct"]).to_i == speedup &&
485
+ round_event_target_key(round.fetch(:target, round["target"])) == target_key
486
+ end
487
+ end
488
+
489
+ def round_event_target_key(target)
490
+ kind = target.fetch(:kind, target["kind"]).to_s
491
+ if kind == "wait"
492
+ [kind, target.fetch(:name, target["name"]).to_s]
493
+ else
494
+ [kind, File.expand_path(target.fetch(:file, target["file"]).to_s), target.fetch(:line, target["line"]).to_i]
495
+ end
496
+ end
497
+
498
+ def round_targets(snapshot)
499
+ line_targets = if line_targets_enabled?
500
+ snapshot[:samples].map do |(path, line), count|
501
+ share = snapshot[:sample_count].positive? ? count.to_f / snapshot[:sample_count] : 0.0
502
+ { target: { kind: "line", file: path, line: line }, share: [share, 0.95].min }
503
+ end
504
+ else
505
+ []
506
+ end
507
+
508
+ wait_targets = if wait_targets_enabled?
509
+ snapshot[:waits].map do |kind, bucket|
510
+ share = snapshot[:duration_ns].positive? ? bucket[:duration_ns].to_f / snapshot[:duration_ns] : 0.0
511
+ { target: { kind: "wait", name: kind }, share: [share, 0.95].min }
512
+ end
513
+ else
514
+ []
515
+ end
516
+
517
+ (line_targets + wait_targets).sort_by { |target| -target[:share] }
518
+ end
519
+
520
+ def mean_latency_ns(bucket)
521
+ return 0 unless bucket[:completed_count].positive?
522
+
523
+ bucket[:total_duration_ns] / bucket[:completed_count]
524
+ end
525
+
526
+ def little_latency_ns(bucket)
527
+ return 0 unless bucket[:end_count].positive?
528
+
529
+ bucket[:inflight_area_ns] / bucket[:end_count]
530
+ end
531
+
532
+ def update_latency_area(bucket, now)
533
+ last = bucket[:last_area_update_ns]
534
+ if last && now > last && bucket[:in_flight].positive?
535
+ bucket[:inflight_area_ns] += (now - last) * bucket[:in_flight]
536
+ end
537
+ bucket[:last_area_update_ns] = now
538
+ end
539
+
540
+ def start_gc_profiler
541
+ return unless defined?(GC::Profiler)
542
+
543
+ @gc_profiler_was_enabled = GC::Profiler.enabled?
544
+ @gc_started_total_time = GC::Profiler.total_time
545
+ GC::Profiler.enable
546
+ rescue StandardError
547
+ nil
548
+ end
549
+
550
+ def record_gc_pause_delta
551
+ return unless defined?(GC::Profiler)
552
+
553
+ total_time = GC::Profiler.total_time
554
+ delta_ns = ((total_time - @gc_started_total_time) * 1_000_000_000).round
555
+ record_wait(:gc_pause, delta_ns) if delta_ns.positive?
556
+ GC::Profiler.disable unless @gc_profiler_was_enabled
557
+ rescue StandardError
558
+ nil
559
+ end
560
+
561
+ def start_fiber_tracepoint
562
+ @fiber_tracepoint = TracePoint.new(:fiber_switch) do
563
+ @fiber_switches += 1
564
+ @fiber_ids[Fiber.current.object_id] = true
565
+ @fiber_thread_ids[Thread.current.object_id] = true
566
+ end
567
+ @fiber_tracepoint.enable
568
+ rescue StandardError
569
+ @fiber_tracepoint = nil
570
+ end
571
+
572
+ def stop_fiber_tracepoint
573
+ @fiber_tracepoint&.disable
574
+ rescue StandardError
575
+ nil
576
+ end
577
+
578
+ def start_thread_sampler
579
+ period = [@sample_period, 0.005].max
580
+ @sampler_thread = Thread.new do
581
+ Thread.current[:corkscrews_internal] = true
582
+ Thread.current.name = "corkscrews thread sampler" if Thread.current.respond_to?(:name=)
583
+ until @stop_requested
584
+ sleep period
585
+ record_thread_samples
586
+ end
587
+ end
588
+ end
589
+
590
+ def stop_thread_sampler
591
+ return unless @sampler_thread
592
+
593
+ @sampler_thread.join(@sample_period * 4)
594
+ @sampler_thread.kill if @sampler_thread.alive?
595
+ end
596
+
597
+ def start_signal_sampler
598
+ @previous_prof_trap = Signal.trap("PROF") { record_signal_sample }
599
+ return if @native_signal_source
600
+
601
+ @setitimer = Fiddle::Function.new(
602
+ Fiddle::Handle::DEFAULT["setitimer"],
603
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
604
+ Fiddle::TYPE_INT
605
+ )
606
+ set_prof_timer(@sample_period)
607
+ end
608
+
609
+ def stop_signal_sampler
610
+ set_prof_timer(0.0) if @setitimer
611
+ Signal.trap("PROF", @previous_prof_trap || "DEFAULT")
612
+ end
613
+
614
+ def register_native_signal_source
615
+ return unless ENV["CORKSCREWS_NATIVE_SIGNALS"] == "1"
616
+ return unless Corkscrews::Native.monitor_available?
617
+
618
+ Corkscrews::Native.register_monitor_thread
619
+ @native_signal_source = true
620
+ rescue StandardError
621
+ @native_signal_source = false
622
+ end
623
+
624
+ def set_prof_timer(seconds)
625
+ usec = (seconds * 1_000_000).round
626
+ payload = itimerval_payload(usec)
627
+ @setitimer.call(2, payload, nil)
628
+ end
629
+
630
+ def itimerval_payload(usec)
631
+ if RbConfig::CONFIG.fetch("host_os").match?(/darwin|bsd/)
632
+ [0, usec, 0, usec].pack("q l x4 q l x4")
633
+ else
634
+ [0, usec, 0, usec].pack("q q q q")
635
+ end
636
+ end
637
+
638
+ def record_signal_sample
639
+ location = caller_locations(1, MAX_BACKTRACE_DEPTH).find do |candidate|
640
+ application_site(candidate.path, candidate.lineno)
641
+ end
642
+ return unless location
643
+
644
+ site = application_site(location.path, location.lineno)
645
+ return unless site
646
+
647
+ if defined?(Corkscrews::Sampler) && native_sample_allowed?(site)
648
+ if Corkscrews::Sampler.respond_to?(:record_site)
649
+ Corkscrews::Sampler.record_site(site_key(site[0]), site[1])
650
+ else
651
+ Corkscrews::Sampler.record_line(site[1])
652
+ end
653
+ end
654
+ record_sample(site, Thread.current.object_id, synchronize: false)
655
+ rescue StandardError
656
+ nil
657
+ end
658
+
659
+ def record_thread_samples
660
+ Thread.list.each do |thread|
661
+ next if thread == Thread.current
662
+ next unless thread.alive?
663
+
664
+ location = thread.backtrace_locations(0, MAX_BACKTRACE_DEPTH)&.find do |candidate|
665
+ application_site(candidate.path, candidate.lineno)
666
+ end
667
+ next unless location
668
+
669
+ site = application_site(location.path, location.lineno)
670
+ record_sample(site, thread.object_id) if site
671
+ end
672
+ rescue StandardError
673
+ nil
674
+ end
675
+
676
+ def record_sample(site, thread_id, synchronize: true)
677
+ return record_sample_unlocked(site, thread_id) unless synchronize
678
+
679
+ synchronize_state do
680
+ record_sample_unlocked(site, thread_id)
681
+ end
682
+ end
683
+
684
+ def record_sample_unlocked(site, thread_id)
685
+ @samples_by_site[site] += 1
686
+ @causal_samples_by_site[site] += 1 if @progress_threads.empty? || @progress_threads.key?(thread_id)
687
+ @sample_count += 1
688
+ advance_experiment_round({ kind: "line", file: site[0], line: site[1] })
689
+ end
690
+
691
+ def advance_experiment_round(fallback_target)
692
+ now = TimeSource.monotonic_ns
693
+ start_round(fallback_target, now) unless @current_experiment
694
+ finish_round(now) if now - @round_started_ns >= @round_max_ns
695
+ end
696
+
697
+ def start_round(fallback_target, now)
698
+ # Paper basis: Coz SOSP'15 Section 3.2 starts each experiment by
699
+ # selecting a target and speedup, then records progress counters.
700
+ # Paper URL: https://arxiv.org/abs/1608.03676
701
+ round = @controller.next_round(
702
+ progress_visits: total_progress_count,
703
+ duration_ns: now - @started_ns,
704
+ targets: active_round_targets(fallback_target),
705
+ history: @round_records
706
+ )
707
+ target = normalize_round_target(round.target)
708
+ @round_index += 1
709
+ @round_started_ns = now
710
+ @round_progress_start = total_progress_count
711
+ @current_experiment = {
712
+ round_index: @round_index,
713
+ target: target,
714
+ site_key: target[:kind] == "line" ? site_key(target[:file]) : 0,
715
+ speedup_pct: round.speedup_pct,
716
+ started_ns: now
717
+ }
718
+ Corkscrews::Native.set_experiment(
719
+ kind: target[:kind].to_sym,
720
+ iseq: @current_experiment[:site_key],
721
+ line: target.fetch(:line, 0).to_i,
722
+ speedup_pct: round.speedup_pct
723
+ )
724
+ end
725
+
726
+ def finish_round(now)
727
+ return unless @current_experiment
728
+
729
+ # Paper basis: Coz SOSP'15 Section 3.2 logs experiment duration,
730
+ # selected target/speedup, delays, and progress visits at round end.
731
+ # Paper URL: https://arxiv.org/abs/1608.03676
732
+ duration_ns = now - @current_experiment[:started_ns]
733
+ speedup_pct = @current_experiment[:speedup_pct]
734
+ share = share_for_target(@current_experiment[:target])
735
+ @round_records << {
736
+ type: "round",
737
+ pid: Process.pid,
738
+ run_id: @run_id,
739
+ repeat_index: @repeat_index,
740
+ round_index: @current_experiment[:round_index],
741
+ target: @current_experiment[:target],
742
+ speedup_pct: speedup_pct,
743
+ baseline: speedup_pct.zero?,
744
+ visits: total_progress_count - @round_progress_start,
745
+ physical_duration_ns: duration_ns,
746
+ virtual_duration_ns: [duration_ns * (1.0 - (share * speedup_pct / 100.0)), 0].max.round,
747
+ waived_debts: @suppressed_delay_ns
748
+ }
749
+ @current_experiment = nil
750
+ end
751
+
752
+ def total_progress_count
753
+ @progress_by_name.values.sum { |bucket| bucket[:count].to_i }
754
+ end
755
+
756
+ def share_for_target(target)
757
+ if target[:kind] == "wait"
758
+ # Paper basis: Ousterhout et al. NSDI'15 Section 2.3 treats
759
+ # blocked time as an upper bound on possible resource improvement.
760
+ # Paper URL: https://www.usenix.org/system/files/conference/nsdi15/nsdi15-paper-ousterhout.pdf
761
+ elapsed_ns = [TimeSource.monotonic_ns - @started_ns, 1].max
762
+ duration_ns = @wait_by_kind.dig(target[:name].to_s, :duration_ns).to_i
763
+ return [[duration_ns.to_f / elapsed_ns, 0.0].max, 0.95].min
764
+ end
765
+
766
+ return 0.0 unless @sample_count.positive?
767
+
768
+ key = [target[:file], target[:line]]
769
+ [[@causal_samples_by_site[key].to_f / @sample_count, 0.0].max, 0.95].min
770
+ end
771
+
772
+ def active_round_targets(fallback_target)
773
+ targets = []
774
+ targets.concat(line_round_targets) if line_targets_enabled?
775
+ targets.concat(wait_round_targets) if wait_targets_enabled?
776
+ targets << normalize_round_target(fallback_target) if targets.empty?
777
+ targets.uniq { |target| round_target_key(target) }
778
+ end
779
+
780
+ def line_round_targets
781
+ total = [@sample_count, 1].max
782
+ @samples_by_site.map do |(path, line), count|
783
+ causal_count = @causal_samples_by_site.fetch([path, line], count)
784
+ {
785
+ kind: "line",
786
+ file: path,
787
+ line: line,
788
+ sample_share: count.to_f / total,
789
+ causal_share: causal_count.to_f / total
790
+ }
791
+ end
792
+ end
793
+
794
+ def wait_round_targets
795
+ elapsed_ns = [TimeSource.monotonic_ns - @started_ns, 1].max
796
+ @wait_by_kind.map do |kind, bucket|
797
+ share = bucket[:duration_ns].to_f / elapsed_ns
798
+ {
799
+ kind: "wait",
800
+ name: kind.to_s,
801
+ sample_share: share,
802
+ causal_share: share
803
+ }
804
+ end
805
+ end
806
+
807
+ def normalize_round_target(target)
808
+ kind = target.fetch(:kind, target["kind"]).to_s
809
+ if kind == "wait"
810
+ { kind: "wait", name: target.fetch(:name, target["name"]).to_s }
811
+ else
812
+ { kind: "line", file: target.fetch(:file, target["file"]).to_s, line: target.fetch(:line, target["line"]).to_i }
813
+ end
814
+ end
815
+
816
+ def round_target_key(target)
817
+ if target[:kind] == "wait"
818
+ [target[:kind], target[:name]]
819
+ else
820
+ [target[:kind], target[:file], target[:line]]
821
+ end
822
+ end
823
+
824
+ def native_sample_allowed?(site)
825
+ delay_ns = delay_for_site(site)
826
+ return true unless delay_ns.positive?
827
+ return true unless @max_slowdown_pct
828
+
829
+ elapsed_ns = TimeSource.monotonic_ns - @started_ns
830
+ budget_ns = (elapsed_ns * (@max_slowdown_pct / 100.0)).round
831
+ if @issued_delay_ns + delay_ns <= budget_ns
832
+ @issued_delay_ns += delay_ns
833
+ true
834
+ else
835
+ @suppressed_delay_ns += delay_ns
836
+ false
837
+ end
838
+ end
839
+
840
+ def delay_for_site(site)
841
+ experiment = @current_experiment
842
+ return 0 unless experiment
843
+ return 0 unless experiment[:speedup_pct].positive?
844
+ return 0 unless experiment[:site_key] == site_key(site[0]) && experiment[:target][:line].to_i == site[1].to_i
845
+
846
+ @sample_period_ns * experiment[:speedup_pct] / 100
847
+ end
848
+
849
+ def site_key(path)
850
+ path.to_s.b.each_byte.reduce(FNV64_OFFSET_BASIS) do |hash, byte|
851
+ ((hash ^ byte) * FNV64_PRIME) & FNV64_MASK
852
+ end
853
+ end
854
+
855
+ def application_site(path, line)
856
+ full_path = File.expand_path(path)
857
+ return nil if full_path.start_with?(INTERNAL_PATH)
858
+ return nil if full_path.include?("/rubygems/")
859
+ return nil if full_path.include?("/bundler/")
860
+
861
+ [full_path, line]
862
+ end
863
+
864
+ def line_targets_enabled?
865
+ %w[lines both].include?(@target_mode)
866
+ end
867
+
868
+ def wait_targets_enabled?
869
+ %w[waits both].include?(@target_mode)
870
+ end
871
+
872
+ def runtime_snapshot
873
+ {
874
+ fiber_switches: @fiber_switches,
875
+ fiber_count: @fiber_ids.length,
876
+ fiber_thread_count: @fiber_thread_ids.length,
877
+ fiber_scheduler: Fiber.respond_to?(:scheduler) && !Fiber.scheduler.nil?,
878
+ ractor_count: ractor_count
879
+ }
880
+ end
881
+
882
+ def ractor_count
883
+ return 1 unless defined?(Ractor)
884
+
885
+ ObjectSpace.each_object(Ractor).count
886
+ rescue StandardError
887
+ 1
888
+ end
889
+
890
+ def max_slowdown_pct
891
+ value = ENV["CORKSCREWS_MAX_SLOWDOWN"]
892
+ return nil if value.nil? || value.empty?
893
+
894
+ parsed = value.to_f
895
+ parsed >= 0.0 ? parsed : nil
896
+ end
897
+
898
+ def write_json(file, payload)
899
+ file.write(JSON.generate(payload))
900
+ file.write("\n")
901
+ end
902
+ end
903
+ end