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 +4 -4
- data/CHANGELOG.md +6 -0
- data/ext/polyrun_coverage_merge/coverage_merge.c +34 -15
- data/lib/polyrun/benchmark/pressure/cooker.rb +191 -0
- data/lib/polyrun/benchmark/pressure/process_sample.rb +127 -0
- data/lib/polyrun/benchmark/pressure.rb +144 -0
- data/lib/polyrun/coverage/merge.rb +33 -6
- data/lib/polyrun/coverage/merge_fragment_meta.rb +7 -1
- data/lib/polyrun/coverage/merge_merge_two.rb +2 -0
- data/lib/polyrun/coverage/merge_native.rb +2 -0
- data/lib/polyrun/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd372c59e18ffd48e6e53d42240cb4893fd165af0399ca6c50ca8a4d14a74b0e
|
|
4
|
+
data.tar.gz: 9aaf900fe002008e213c1f8b48b9c7867ad685db79946d72760f40a92c7052f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ?
|
|
139
|
-
VALUE right_hit = index < right_len ?
|
|
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),
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
31
|
+
# Fold one fragment at a time so peak RSS does not retain every blob concurrently.
|
|
30
32
|
metas = []
|
|
31
|
-
|
|
33
|
+
merged_blob = nil
|
|
32
34
|
paths.each do |path|
|
|
33
|
-
doc = JSON.parse(File.
|
|
35
|
+
doc = JSON.parse(File.binread(path))
|
|
34
36
|
metas << extract_doc_meta(doc)
|
|
35
|
-
|
|
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.
|
|
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.
|
|
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
|
data/lib/polyrun/version.rb
CHANGED
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.
|
|
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
|