polyrun 2.2.8 → 2.2.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f6a7a0e969581e53f92c3e1bd4dfbb046c8321fb25601d58be12d4cc8cb6a96
4
- data.tar.gz: 0c4625804e0ee0ce3f695570001658f9d907d9102c059258efa9c00fdf16d669
3
+ metadata.gz: dd372c59e18ffd48e6e53d42240cb4893fd165af0399ca6c50ca8a4d14a74b0e
4
+ data.tar.gz: 9aaf900fe002008e213c1f8b48b9c7867ad685db79946d72760f40a92c7052f6
5
5
  SHA512:
6
- metadata.gz: 663f89b311212915efcb030db4cf88feb7f3f78b981746d63ee8c5548fec1abd23e13df3b307f4173601e4b3b829dab9ac56664caecfd929ed48c585deec58c2
7
- data.tar.gz: be540c7af7282a2868408a6e8fa7837976ae2ffe443acbec7e65bb6e881d0bce419a344cf75573cadb2af0f562272032eabaf30564e2fab9a998f76029e37c07
6
+ metadata.gz: f3ba035a9df88c6722deaf3c632a213fdc11fa94554b260136fe3500e8fbe3e26825b6ef9f59732a07db3d82cf562a09553a14b677f0e8beb0ab50574775806b
7
+ data.tar.gz: aac4874eb9000f920691c8796c1ee35e91ed7555b4da84d9d9e1cb2fbce8783c3a6de8ef7dd82aaa02f941e55291a04c172a87fcba20ae4cf10f2a45b611a01e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 2.2.9 (2026-11-07)
4
+
5
+ - Add `Polyrun::Benchmark::Pressure` for load benches: wall/CPU/RSS/heap/GC plus optional storage and network byte deltas, with target ceilings for iterative pressure runs.
6
+ - Add `benchmarks/load.rb`, `benchmarks/cooker.rb`, and Make targets (`bench-load`, `bench-calibrate`, cooker propose/gate) for coverage-merge pressure matrices; JSON under `benchmarks/output/`.
7
+ - Speed coverage merge on the hot path: faster fragment read/parse, cheaper empty-meta merges, and native merge micro-optimizations (hash capacity, Fixnum line hits, empty-operand early return).
8
+
3
9
  ## 2.2.8 (2026-11-04)
4
10
 
5
11
  - Honor top-level `workers:` in `polyrun.yml` when `POLYRUN_WORKERS` is unset (`polyrun config workers`, `start`, `run-shards`, `run-queue`, `doctor`).
@@ -16,12 +16,12 @@ static ID id_coverage;
16
16
  static ID id_relevant;
17
17
  static ID id_covered;
18
18
  static ID id_sort_branches_for_native;
19
+ static ID id_keys;
20
+ static ID id_plus;
21
+ static ID id_gt;
19
22
 
20
- static VALUE
21
- ignored_string(void)
22
- {
23
- return rb_str_new_cstr("ignored");
24
- }
23
+ static VALUE str_ignored;
24
+ static VALUE ary_empty_lines;
25
25
 
26
26
  static int
27
27
  ignored_hit_p(VALUE value)
@@ -67,7 +67,7 @@ line_hit_positive_p(VALUE value)
67
67
  return 0;
68
68
  }
69
69
 
70
- VALUE comparison = rb_funcall(as_int, rb_intern(">"), 1, LONG2FIX(0));
70
+ VALUE comparison = rb_funcall(as_int, id_gt, 1, LONG2FIX(0));
71
71
  return RTEST(comparison);
72
72
  }
73
73
 
@@ -75,7 +75,7 @@ static VALUE
75
75
  coerce_line_array(VALUE value)
76
76
  {
77
77
  if (NIL_P(value)) {
78
- return rb_ary_new();
78
+ return ary_empty_lines;
79
79
  }
80
80
  if (!RB_TYPE_P(value, T_ARRAY)) {
81
81
  rb_raise(rb_eTypeError, "lines must be an Array");
@@ -88,7 +88,7 @@ static VALUE
88
88
  line_array_for_count(VALUE value)
89
89
  {
90
90
  if (NIL_P(value) || !RB_TYPE_P(value, T_ARRAY)) {
91
- return rb_ary_new();
91
+ return ary_empty_lines;
92
92
  }
93
93
 
94
94
  return value;
@@ -104,14 +104,17 @@ merge_line_hits(VALUE left, VALUE right)
104
104
  return left;
105
105
  }
106
106
  if (ignored_hit_p(left) || ignored_hit_p(right)) {
107
- return ignored_string();
107
+ return str_ignored;
108
108
  }
109
109
 
110
110
  VALUE left_i = line_hit_to_i(left);
111
111
  VALUE right_i = line_hit_to_i(right);
112
112
 
113
113
  if (!NIL_P(left_i) && !NIL_P(right_i)) {
114
- return rb_funcall(left_i, rb_intern("+"), 1, right_i);
114
+ if (RB_FIXNUM_P(left_i) && RB_FIXNUM_P(right_i)) {
115
+ return LONG2FIX(FIX2LONG(left_i) + FIX2LONG(right_i));
116
+ }
117
+ return rb_funcall(left_i, id_plus, 1, right_i);
115
118
  }
116
119
  if (NIL_P(left_i) && !NIL_P(right_i)) {
117
120
  return right_i;
@@ -134,9 +137,12 @@ merge_line_arrays(VALUE left, VALUE right)
134
137
  long max_len = left_len > right_len ? left_len : right_len;
135
138
  VALUE out = rb_ary_new2(max_len);
136
139
 
140
+ const VALUE *left_ptr = RARRAY_CONST_PTR(left);
141
+ const VALUE *right_ptr = RARRAY_CONST_PTR(right);
142
+
137
143
  for (long index = 0; index < max_len; index++) {
138
- VALUE left_hit = index < left_len ? rb_ary_entry(left, index) : Qnil;
139
- VALUE right_hit = index < right_len ? rb_ary_entry(right, index) : Qnil;
144
+ VALUE left_hit = index < left_len ? left_ptr[index] : Qnil;
145
+ VALUE right_hit = index < right_len ? right_ptr[index] : Qnil;
140
146
  rb_ary_store(out, index, merge_line_hits(left_hit, right_hit));
141
147
  }
142
148
 
@@ -185,7 +191,7 @@ static VALUE
185
191
  merge_branch_entries(VALUE left, VALUE right)
186
192
  {
187
193
  VALUE out = rb_hash_dup(left);
188
- VALUE sum = rb_funcall(coverage_count(left), rb_intern("+"), 1, coverage_count(right));
194
+ VALUE sum = rb_funcall(coverage_count(left), id_plus, 1, coverage_count(right));
189
195
  rb_hash_aset(out, str_coverage, sum);
190
196
 
191
197
  return out;
@@ -202,7 +208,7 @@ merge_module_value(void)
202
208
  static VALUE
203
209
  hash_keys(VALUE hash)
204
210
  {
205
- return rb_funcall(hash, rb_intern("keys"), 0);
211
+ return rb_funcall(hash, id_keys, 0);
206
212
  }
207
213
 
208
214
  static VALUE
@@ -395,7 +401,12 @@ merge_two(VALUE module, VALUE left, VALUE right)
395
401
  secondary = left;
396
402
  }
397
403
 
398
- VALUE out = rb_hash_new();
404
+ if (RHASH_SIZE(secondary) == 0) {
405
+ return rb_hash_dup(primary);
406
+ }
407
+
408
+ long capa = (long)RHASH_SIZE(primary);
409
+ VALUE out = rb_hash_new_capa(capa > 0 ? capa : 0);
399
410
  VALUE primary_keys = hash_keys(primary);
400
411
  long primary_length = RARRAY_LEN(primary_keys);
401
412
 
@@ -472,18 +483,26 @@ Init_polyrun_coverage_merge(void)
472
483
  id_relevant = rb_intern("relevant");
473
484
  id_covered = rb_intern("covered");
474
485
  id_sort_branches_for_native = rb_intern("sort_branches_for_native");
486
+ id_keys = rb_intern("keys");
487
+ id_plus = rb_intern("+");
488
+ id_gt = rb_intern(">");
475
489
  str_lines = rb_obj_freeze(rb_str_new_cstr("lines"));
476
490
  str_branches = rb_obj_freeze(rb_str_new_cstr("branches"));
477
491
  str_type = rb_obj_freeze(rb_str_new_cstr("type"));
478
492
  str_start_line = rb_obj_freeze(rb_str_new_cstr("start_line"));
479
493
  str_end_line = rb_obj_freeze(rb_str_new_cstr("end_line"));
480
494
  str_coverage = rb_obj_freeze(rb_str_new_cstr("coverage"));
495
+ str_ignored = rb_obj_freeze(rb_str_new_cstr("ignored"));
496
+ ary_empty_lines = rb_ary_new();
497
+ rb_obj_freeze(ary_empty_lines);
481
498
  rb_global_variable(&str_lines);
482
499
  rb_global_variable(&str_branches);
483
500
  rb_global_variable(&str_type);
484
501
  rb_global_variable(&str_start_line);
485
502
  rb_global_variable(&str_end_line);
486
503
  rb_global_variable(&str_coverage);
504
+ rb_global_variable(&str_ignored);
505
+ rb_global_variable(&ary_empty_lines);
487
506
 
488
507
  VALUE module = rb_define_module("PolyrunCoverageMerge");
489
508
  rb_define_singleton_method(module, "merge_line_arrays", merge_line_arrays_method, 2);
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Polyrun
8
+ module Benchmark
9
+ module Pressure
10
+ # Episode helpers for amkisko/pressure-benchmarking prayer: multi-sample measure,
11
+ # propose ceilings, calibrate stage separation, and cooker iteration logs.
12
+ module Cooker
13
+ DEFAULT_SAMPLES = 3
14
+ DEFAULT_HEADROOM = 1.15
15
+ DEFAULT_COOKER_N = 10
16
+ SERIES_KEYS = %w[
17
+ wall_s cpu_s rss_delta_bytes heap_delta_bytes storage_delta_bytes
18
+ network_in_bytes network_out_bytes allocated_objects
19
+ ].freeze
20
+
21
+ module_function
22
+
23
+ def measure_samples(n:, label:, ops: nil, gc_start: true, extras: {}, &block)
24
+ raise ArgumentError, "samples-per-load must be >= 1" if n < 1
25
+
26
+ samples = Array.new(n) do |i|
27
+ Pressure.measure(
28
+ label: "#{label}_s#{i + 1}",
29
+ ops: ops,
30
+ gc_start: gc_start,
31
+ extras: extras,
32
+ &block
33
+ )
34
+ end
35
+ aggregate(samples, label: label, basis: extras.fetch("basis", "median"))
36
+ end
37
+
38
+ def aggregate(samples, label:, basis: "median")
39
+ metrics_list = samples.map { |s| flatten_metrics(s["metrics"]) }
40
+ keys = SERIES_KEYS
41
+ summary = {}
42
+ keys.each do |key|
43
+ values = metrics_list.filter_map { |m| m[key] }
44
+ next if values.empty?
45
+
46
+ summary[key] = {
47
+ "samples" => values,
48
+ "median" => percentile(values, 0.5),
49
+ "max" => values.max,
50
+ "min" => values.min,
51
+ "basis_value" => (basis.to_s == "max") ? values.max : percentile(values, 0.5)
52
+ }
53
+ end
54
+ wall = summary.dig("wall_s", "basis_value")
55
+ ops = samples.first && samples.first["ops"]
56
+ {
57
+ "label" => label.to_s,
58
+ "ops" => ops,
59
+ "ops_per_s" => (ops && wall&.positive?) ? ops / wall : nil,
60
+ "basis" => basis.to_s,
61
+ "n" => samples.length,
62
+ "samples" => samples,
63
+ "series" => summary,
64
+ "metrics" => summary.transform_values { |h| h["basis_value"] },
65
+ "gateable" => samples.length >= DEFAULT_SAMPLES
66
+ }
67
+ end
68
+
69
+ def flatten_metrics(metrics)
70
+ flat = metrics.dup
71
+ flat["allocated_objects"] = metrics.dig("gc", "allocated_objects")
72
+ flat
73
+ end
74
+
75
+ def propose_ceilings(series_summary, headroom: DEFAULT_HEADROOM, keys: nil)
76
+ keys ||= SERIES_KEYS
77
+ keys.each_with_object({}) do |key, out|
78
+ entry = series_summary[key] || series_summary.dig("series", key)
79
+ next unless entry
80
+
81
+ value = entry.is_a?(Hash) ? entry["basis_value"] : entry
82
+ next if value.nil?
83
+
84
+ out[key] = value.to_f * headroom
85
+ end
86
+ end
87
+
88
+ def gate(series_or_metrics, ceilings)
89
+ metrics = series_or_metrics["metrics"] || series_or_metrics
90
+ failures = []
91
+ ceilings.each do |key, limit|
92
+ next if limit.nil?
93
+ next unless metrics.key?(key)
94
+
95
+ failures << key if metrics[key].to_f > limit.to_f
96
+ end
97
+ {"pass" => failures.empty?, "failures" => failures, "metrics" => metrics, "ceilings" => ceilings}
98
+ end
99
+
100
+ def calibrate!(stages)
101
+ # stages: ordered Array of [name, known_rank, callable]
102
+ # known_rank must be strictly increasing with expected wall cost.
103
+ measured = stages.map do |name, known_rank, callable|
104
+ result = Pressure.measure(label: "calibrate_#{name}", ops: 1, gc_start: true) { callable.call }
105
+ {
106
+ "stage" => name.to_s,
107
+ "known_rank" => known_rank,
108
+ "wall_s" => result["metrics"]["wall_s"],
109
+ "cpu_s" => result["metrics"]["cpu_s"]
110
+ }
111
+ end
112
+ by_wall = measured.sort_by { |row| row["wall_s"] }
113
+ by_known = measured.sort_by { |row| row["known_rank"] }
114
+ order_ok = by_wall.map { |r| r["stage"] } == by_known.map { |r| r["stage"] }
115
+ distinct = measured.map { |r| r["wall_s"] }.uniq.length == measured.length
116
+ {
117
+ "pass" => order_ok && distinct,
118
+ "order_ok" => order_ok,
119
+ "distinct_wall" => distinct,
120
+ "stages" => measured,
121
+ "report" => format_calibrate(measured, order_ok, distinct)
122
+ }
123
+ end
124
+
125
+ def format_calibrate(measured, order_ok, distinct)
126
+ lines = measured.map { |r| format("%s rank=%s wall=%.6fs cpu=%.6fs", r["stage"], r["known_rank"], r["wall_s"], r["cpu_s"]) }
127
+ status = (order_ok && distinct) ? "PASS" : "FAIL"
128
+ ([status, "order_ok=#{order_ok}", "distinct_wall=#{distinct}"] + lines).join(" | ")
129
+ end
130
+
131
+ def load_ceilings(path)
132
+ return {} unless File.file?(path)
133
+
134
+ JSON.parse(File.read(path))
135
+ end
136
+
137
+ def write_ceilings!(path, ceilings, meta: {})
138
+ FileUtils.mkdir_p(File.dirname(path))
139
+ payload = {
140
+ "meta" => meta.merge("recorded_at" => Time.now.utc.iso8601),
141
+ "ceilings" => ceilings
142
+ }
143
+ File.write(path, JSON.pretty_generate(payload))
144
+ path
145
+ end
146
+
147
+ def new_episode(repository_root:, binding:)
148
+ {
149
+ "started_at" => Time.now.utc.iso8601,
150
+ "finished_at" => nil,
151
+ "repository_root" => repository_root,
152
+ "binding" => binding,
153
+ "calibrate" => nil,
154
+ "load_steps" => [],
155
+ "ceilings_before" => nil,
156
+ "ceilings_after" => nil,
157
+ "iterations" => [],
158
+ "accepted" => false,
159
+ "stop_reason" => nil
160
+ }
161
+ end
162
+
163
+ def append_iteration!(episode, iteration:, primary:, hypothesis:, change:, prove:)
164
+ episode["iterations"] << {
165
+ "n" => iteration,
166
+ "primary_series" => primary,
167
+ "hypothesis" => hypothesis,
168
+ "change" => change,
169
+ "prove" => prove
170
+ }
171
+ episode
172
+ end
173
+
174
+ def write_episode!(episode, path:)
175
+ episode["finished_at"] = Time.now.utc.iso8601
176
+ FileUtils.mkdir_p(File.dirname(path))
177
+ File.write(path, JSON.pretty_generate(episode))
178
+ path
179
+ end
180
+
181
+ def percentile(values, fraction)
182
+ sorted = values.map(&:to_f).sort
183
+ return sorted.first if sorted.length == 1
184
+
185
+ idx = ((sorted.length - 1) * fraction).round
186
+ sorted[idx]
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "objspace"
4
+ require "open3"
5
+
6
+ module Polyrun
7
+ module Benchmark
8
+ module Pressure
9
+ # Wall/CPU/RSS/heap/GC sampling and diffs for {Pressure.measure}.
10
+ module ProcessSample
11
+ module_function
12
+
13
+ def sample
14
+ times = Process.times
15
+ {
16
+ "wall_s" => monotonic_s,
17
+ "cpu_user_s" => times.utime,
18
+ "cpu_system_s" => times.stime,
19
+ "rss_bytes" => rss_bytes,
20
+ "heap_bytes" => heap_bytes,
21
+ "gc" => gc_sample,
22
+ "storage_bytes" => 0,
23
+ "network_in_bytes" => 0,
24
+ "network_out_bytes" => 0
25
+ }
26
+ end
27
+
28
+ def diff(before, after_sample, extras: {})
29
+ wall_s = after_sample["wall_s"] - before["wall_s"]
30
+ cpu = cpu_diff(before, after_sample)
31
+ memory = memory_diff(before, after_sample)
32
+ io = io_diff(before, after_sample, extras)
33
+ {
34
+ "wall_s" => wall_s,
35
+ "cpu_user_s" => cpu[:user],
36
+ "cpu_system_s" => cpu[:system],
37
+ "cpu_s" => cpu[:total],
38
+ "cpu_pct" => wall_s.positive? ? (cpu[:total] / wall_s) * 100.0 : 0.0,
39
+ "rss_bytes" => memory[:rss],
40
+ "rss_delta_bytes" => memory[:rss_delta],
41
+ "heap_bytes" => memory[:heap],
42
+ "heap_delta_bytes" => memory[:heap_delta],
43
+ "gc" => gc_diff(before["gc"], after_sample["gc"]),
44
+ "storage_delta_bytes" => io[:storage],
45
+ "network_in_bytes" => io[:network_in],
46
+ "network_out_bytes" => io[:network_out]
47
+ }
48
+ end
49
+
50
+ def monotonic_s
51
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
52
+ end
53
+
54
+ def rss_bytes
55
+ if File.readable?("/proc/self/status")
56
+ status = File.read("/proc/self/status")
57
+ if (match = status.match(/^VmRSS:\s+(\d+)\s+kB/))
58
+ return match[1].to_i * 1024
59
+ end
60
+ end
61
+
62
+ stdout, status = Open3.capture2("ps", "-o", "rss=", "-p", Process.pid.to_s)
63
+ return 0 unless status.success?
64
+
65
+ stdout.strip.to_i * 1024
66
+ rescue
67
+ 0
68
+ end
69
+
70
+ def heap_bytes
71
+ ObjectSpace.memsize_of_all
72
+ rescue
73
+ 0
74
+ end
75
+
76
+ def gc_sample
77
+ stat = GC.stat
78
+ {
79
+ "count" => stat[:count],
80
+ "allocated_objects" => stat[:total_allocated_objects],
81
+ "heap_live_slots" => stat[:heap_live_slots],
82
+ "heap_free_slots" => stat[:heap_free_slots],
83
+ "major_gc_count" => stat[:major_gc_count],
84
+ "minor_gc_count" => stat[:minor_gc_count]
85
+ }
86
+ end
87
+
88
+ def gc_diff(before, after_sample)
89
+ {
90
+ "count" => after_sample["count"] - before["count"],
91
+ "allocated_objects" => after_sample["allocated_objects"] - before["allocated_objects"],
92
+ "heap_live_slots" => after_sample["heap_live_slots"],
93
+ "heap_live_delta" => after_sample["heap_live_slots"] - before["heap_live_slots"],
94
+ "major_gc_count" => after_sample["major_gc_count"] - before["major_gc_count"],
95
+ "minor_gc_count" => after_sample["minor_gc_count"] - before["minor_gc_count"]
96
+ }
97
+ end
98
+
99
+ def cpu_diff(before, after_sample)
100
+ user = after_sample["cpu_user_s"] - before["cpu_user_s"]
101
+ system = after_sample["cpu_system_s"] - before["cpu_system_s"]
102
+ {user: user, system: system, total: user + system}
103
+ end
104
+
105
+ def memory_diff(before, after_sample)
106
+ {
107
+ rss: after_sample["rss_bytes"],
108
+ rss_delta: after_sample["rss_bytes"] - before["rss_bytes"],
109
+ heap: after_sample["heap_bytes"],
110
+ heap_delta: after_sample["heap_bytes"] - before["heap_bytes"]
111
+ }
112
+ end
113
+
114
+ def io_diff(before, after_sample, extras)
115
+ {
116
+ storage: (after_sample["storage_bytes"] - before["storage_bytes"]) +
117
+ extras.fetch("storage_delta_bytes", 0),
118
+ network_in: (after_sample["network_in_bytes"] - before["network_in_bytes"]) +
119
+ extras.fetch("network_in_bytes", 0),
120
+ network_out: (after_sample["network_out_bytes"] - before["network_out_bytes"]) +
121
+ extras.fetch("network_out_bytes", 0)
122
+ }
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "socket"
6
+ require_relative "pressure/process_sample"
7
+
8
+ module Polyrun
9
+ module Benchmark
10
+ # Process resource sampler for pressure / load benches.
11
+ #
12
+ # Measures wall time, CPU (user+system), RSS, GC / heap, optional storage and
13
+ # network byte counters. Callers raise load (N, threads, payload size) and lower
14
+ # resource targets after each successful round.
15
+ #
16
+ # Usage:
17
+ # result = Polyrun::Benchmark::Pressure.measure(label: "merge_disk", ops: 8) do
18
+ # merge_files(...)
19
+ # end
20
+ # Polyrun::Benchmark::Pressure.write_results!([result], repository_root: Dir.pwd)
21
+ module Pressure
22
+ module_function
23
+
24
+ DEFAULT_TARGET_FACTOR = 0.85
25
+
26
+ def sample
27
+ ProcessSample.sample
28
+ end
29
+
30
+ def diff(before, after_sample, extras: {})
31
+ ProcessSample.diff(before, after_sample, extras: extras)
32
+ end
33
+
34
+ def measure(label:, ops: nil, gc_start: true, extras: {})
35
+ GC.start if gc_start
36
+ before = sample
37
+ started = ProcessSample.monotonic_s
38
+ yield
39
+ wall_s = ProcessSample.monotonic_s - started
40
+ after_sample = sample
41
+ metrics = diff(before, after_sample, extras: extras)
42
+ metrics["wall_s"] = wall_s
43
+ metrics["cpu_pct"] = wall_s.positive? ? (metrics["cpu_s"] / wall_s) * 100.0 : 0.0
44
+ result = {
45
+ "label" => label.to_s,
46
+ "ops" => ops,
47
+ "ops_per_s" => (ops && wall_s.positive?) ? ops / wall_s : nil,
48
+ "metrics" => metrics,
49
+ "targets" => extras["targets"],
50
+ "pass" => targets_met?(metrics, extras["targets"])
51
+ }
52
+ result["report"] = format_report(result)
53
+ result
54
+ end
55
+
56
+ def targets_from(metrics, factor: DEFAULT_TARGET_FACTOR)
57
+ {
58
+ "wall_s" => metrics["wall_s"] * factor,
59
+ "cpu_s" => metrics["cpu_s"] * factor,
60
+ "rss_delta_bytes" => [metrics["rss_delta_bytes"] * factor, 0].max,
61
+ "heap_delta_bytes" => [metrics["heap_delta_bytes"] * factor, 0].max,
62
+ "storage_delta_bytes" => [metrics["storage_delta_bytes"] * factor, 0].max,
63
+ "network_in_bytes" => metrics["network_in_bytes"] * factor,
64
+ "network_out_bytes" => metrics["network_out_bytes"] * factor
65
+ }
66
+ end
67
+
68
+ def targets_met?(metrics, targets)
69
+ return nil if targets.nil? || targets.empty?
70
+
71
+ targets.all? do |key, limit|
72
+ next true if limit.nil?
73
+ next true unless metrics.key?(key)
74
+
75
+ metrics[key].to_f <= limit.to_f
76
+ end
77
+ end
78
+
79
+ def format_report(result)
80
+ m = result["metrics"]
81
+ parts = format_report_core(result, m)
82
+ append_format_report_extras!(parts, result, m)
83
+ parts.join(" | ")
84
+ end
85
+
86
+ def write_results!(results, repository_root: Dir.pwd, name: "load")
87
+ dir = File.join(repository_root, "benchmarks", "output")
88
+ FileUtils.mkdir_p(dir)
89
+ stamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
90
+ path = File.join(dir, "#{stamp}_#{name}.json")
91
+ payload = {
92
+ "meta" => {
93
+ "recorded_at" => Time.now.utc.iso8601,
94
+ "ruby" => RUBY_VERSION,
95
+ "engine" => RUBY_ENGINE,
96
+ "platform" => RUBY_PLATFORM,
97
+ "polyrun_version" => defined?(Polyrun::VERSION) ? Polyrun::VERSION : nil,
98
+ "hostname" => Socket.gethostname
99
+ },
100
+ "results" => results
101
+ }
102
+ File.write(path, JSON.pretty_generate(payload))
103
+ path
104
+ end
105
+
106
+ def human_bytes(value)
107
+ n = value.to_f
108
+ units = %w[B KiB MiB GiB]
109
+ i = 0
110
+ while n.abs >= 1024.0 && i < units.length - 1
111
+ n /= 1024.0
112
+ i += 1
113
+ end
114
+ format("%.1f%s", n, units[i])
115
+ end
116
+
117
+ def format_report_core(result, metrics)
118
+ [
119
+ result["label"],
120
+ format("wall=%.4fs", metrics["wall_s"]),
121
+ format("cpu=%.4fs (%.0f%%)", metrics["cpu_s"], metrics["cpu_pct"]),
122
+ format("rss=%s (Δ%s)", human_bytes(metrics["rss_bytes"]), human_bytes(metrics["rss_delta_bytes"])),
123
+ format("heap_Δ=%s", human_bytes(metrics["heap_delta_bytes"])),
124
+ format("alloc=%d", metrics.dig("gc", "allocated_objects") || 0)
125
+ ]
126
+ end
127
+
128
+ def append_format_report_extras!(parts, result, metrics)
129
+ parts << format("ops/s=%.1f", result["ops_per_s"]) if result["ops_per_s"]
130
+ if metrics["storage_delta_bytes"].to_i != 0
131
+ parts << format("storage_Δ=%s", human_bytes(metrics["storage_delta_bytes"]))
132
+ end
133
+ net = metrics["network_in_bytes"].to_i + metrics["network_out_bytes"].to_i
134
+ parts << format("net=%s", human_bytes(net)) if net != 0
135
+ return if result["pass"].nil?
136
+
137
+ parts << (result["pass"] ? "PASS" : "FAIL targets")
138
+ end
139
+ private_class_method :format_report_core, :append_format_report_extras!
140
+ end
141
+ end
142
+ end
143
+
144
+ require_relative "pressure/cooker"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "json"
2
4
 
3
5
  module Polyrun
@@ -26,15 +28,15 @@ module Polyrun
26
28
  def merge_fragments(paths)
27
29
  return {blob: {}, meta: {}, groups: nil} if paths.empty?
28
30
 
29
- # Parse one fragment at a time: keep only meta + blob so peak RSS drops after each fold step.
31
+ # Fold one fragment at a time so peak RSS does not retain every blob concurrently.
30
32
  metas = []
31
- blobs = []
33
+ merged_blob = nil
32
34
  paths.each do |path|
33
- doc = JSON.parse(File.read(path))
35
+ doc = JSON.parse(File.binread(path))
34
36
  metas << extract_doc_meta(doc)
35
- blobs << extract_coverage_blob(doc)
37
+ blob = coverage_blob_from_doc(doc)
38
+ merged_blob = merged_blob.nil? ? blob : merge_two(merged_blob, blob)
36
39
  end
37
- merged_blob = merge_blob_tree(blobs)
38
40
  merged_meta = merge_fragment_metas_from_metas(metas)
39
41
  merged_blob = apply_track_files_once_after_merge(merged_blob, merged_meta)
40
42
  groups_payload = recompute_groups_from_meta(merged_blob, merged_meta)
@@ -96,13 +98,33 @@ module Polyrun
96
98
  end
97
99
 
98
100
  def parse_file(path)
99
- text = File.read(path)
101
+ text = File.binread(path)
100
102
  data = JSON.parse(text)
101
103
  extract_coverage_blob(data)
102
104
  end
103
105
 
104
106
  # Picks top-level export `coverage`, merges all suite entries (e.g. RSpec + Minitest),
105
107
  # and combines both when present.
108
+ # Hot path: cooker and worker fragments are { "coverage" => Hash, "meta" => ... } without
109
+ # nested per-tool suites under extra top-level keys.
110
+ def coverage_blob_from_doc(doc)
111
+ return extract_coverage_blob(doc) unless doc.is_a?(Hash)
112
+
113
+ top = doc["coverage"]
114
+ return extract_coverage_blob(doc) unless top.is_a?(Hash)
115
+
116
+ doc.each_key do |k|
117
+ next if k == "coverage"
118
+
119
+ v = doc[k]
120
+ if v.is_a?(Hash) && v["coverage"].is_a?(Hash)
121
+ return extract_coverage_blob(doc)
122
+ end
123
+ end
124
+
125
+ top
126
+ end
127
+
106
128
  def extract_coverage_blob(data)
107
129
  return {} unless data.is_a?(Hash)
108
130
 
@@ -121,6 +143,11 @@ module Polyrun
121
143
  return {}
122
144
  end
123
145
 
146
+ if nested.size == 1
147
+ only = nested.first
148
+ return top.is_a?(Hash) ? merge_two(top, only) : only
149
+ end
150
+
124
151
  merged = nested.reduce { |acc, el| merge_two(acc, el) }
125
152
  top.is_a?(Hash) ? merge_two(top, merged) : merged
126
153
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Polyrun
2
4
  module Coverage
3
5
  module Merge
@@ -9,6 +11,7 @@ module Polyrun
9
11
 
10
12
  def merge_fragment_metas_from_metas(metas)
11
13
  return {} if metas.empty?
14
+ return {} if metas.all? { |m| m.nil? || m.empty? }
12
15
 
13
16
  base = metas.first.dup
14
17
  roots = metas.map { |m| m["polyrun_coverage_root"] }.compact
@@ -43,7 +46,10 @@ module Polyrun
43
46
  end
44
47
 
45
48
  def merge_fragment_meta_warn_track_files!(tfs)
46
- return if tfs.map { |tf| JSON.generate(normalize_track_files_meta(tf)) }.uniq.size <= 1
49
+ return if tfs.empty?
50
+
51
+ normalized = tfs.map { |tf| normalize_track_files_meta(tf) }
52
+ return if normalized.uniq.size <= 1
47
53
 
48
54
  Polyrun::Log.warn "Polyrun merge-coverage: polyrun_track_files differs across fragments; using first."
49
55
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Polyrun
2
4
  module Coverage
3
5
  module Merge
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Polyrun
2
4
  module Coverage
3
5
  module MergeNative
@@ -1,3 +1,3 @@
1
1
  module Polyrun
2
- VERSION = "2.2.8"
2
+ VERSION = "2.2.9"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: polyrun
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.8
4
+ version: 2.2.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -210,6 +210,9 @@ files:
210
210
  - ext/polyrun_coverage_merge/coverage_merge.c
211
211
  - ext/polyrun_coverage_merge/extconf.rb
212
212
  - lib/polyrun.rb
213
+ - lib/polyrun/benchmark/pressure.rb
214
+ - lib/polyrun/benchmark/pressure/cooker.rb
215
+ - lib/polyrun/benchmark/pressure/process_sample.rb
213
216
  - lib/polyrun/benchmark/profile.rb
214
217
  - lib/polyrun/benchmark/report.rb
215
218
  - lib/polyrun/cli.rb