hecks 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b63cae5fd3cc67091185210b80a7fa73e42977f28db96563ad46c50d37dc462
4
- data.tar.gz: 0b5ec871c1ba7bf000bc2a65fdd308c9618a14ae07977fe76a2ed06a7e434399
3
+ metadata.gz: 2220925ed910b9c63dd34c3fc75448f6cff634a64be3fb67461985d462e83563
4
+ data.tar.gz: 618319249d19f123171d300578ba8d70556391371457700504c92ba4bd389935
5
5
  SHA512:
6
- metadata.gz: 47a19c9674469250e49357f8ea57087721274c0a749a4006d3674e5c8d19babe8d57a692cc4750c7de34960a863ddad0596e7587abe8f9da2c6392e6488d5b52
7
- data.tar.gz: 320fbf1791da47bb4247d56ab7f3943dd38ec08dff630eda886955ac24b80b4bf69f59040305f231735e96e586b6f6ca3b6c0068fc44d08995163834dc305dd3
6
+ metadata.gz: a24cd51e0ba290e819645aec45cbc8a55c9978cb1c5fed673180db103a050a77516ebcd224d0102fad523161ce4776866b5702acf790dd0751282c2c399e73a9
7
+ data.tar.gz: a779523871cd0e81d87c26305bed16e51916723ef544e55ccc30d669b6fdb38b825ee2a05515ae9f8c2f40ff31e4faa57f4d5dffc0dac68146d900d112830d92
@@ -0,0 +1,74 @@
1
+ require "optparse"
2
+ require_relative "suite"
3
+ require_relative "report"
4
+
5
+ module Hecks
6
+ module Bench
7
+ # The command line behind `bin/bench`: parses flags, runs the `Suite`, prints the report.
8
+ #
9
+ # Kept in `lib/` so a spec can drive it with a tiny configuration and read what it
10
+ # prints, rather than shelling out to a script.
11
+ module CLI
12
+ # The defaults `bin/bench` runs with when no flag says otherwise.
13
+ DEFAULTS = { domains: %w[pizzas banking], targets: Suite::TARGETS, warmup: 200, iterations: 1000,
14
+ runs: 3 }.freeze
15
+
16
+ module_function
17
+
18
+ # Runs the benchmark described by the command line and prints its report.
19
+ #
20
+ # @param argv [Array<String>] the command-line arguments
21
+ # @param out [#puts] where the report goes
22
+ # @param err [#puts] where progress and usage errors go
23
+ # @return [Integer] the process exit status: 0 on success, 2 for a usage error
24
+ def run(argv, out: $stdout, err: $stderr)
25
+ options = parse(argv)
26
+ result = Suite.call(options.fetch(:config), log: err)
27
+ out.puts(options[:format] == "json" ? Report.json(result) : Report.markdown(result))
28
+ File.write(options[:output], Report.json(result)) if options[:output]
29
+ 0
30
+ rescue OptionParser::ParseError, ArgumentError => e
31
+ err.puts "bin/bench: #{e.message}"
32
+ 2
33
+ end
34
+
35
+ # Turns command-line arguments into a configuration and output choices.
36
+ #
37
+ # @param argv [Array<String>] the command-line arguments
38
+ # @return [Hash{Symbol => Object}] `:config` (a `Suite::Config`), `:format` and `:output`
39
+ # @raise [OptionParser::ParseError] on an unknown or malformed flag
40
+ def parse(argv)
41
+ values = DEFAULTS.dup
42
+ extras = { format: "markdown", output: nil, rust_binary: nil }
43
+ parser(values, extras).parse(argv.dup)
44
+ { config: Suite::Config.new(**values, rust_binary: extras[:rust_binary]),
45
+ format: extras[:format], output: extras[:output] }
46
+ end
47
+
48
+ # Builds the flag parser.
49
+ #
50
+ # @param values [Hash{Symbol => Object}] the `Suite::Config` fields, filled in as flags parse
51
+ # @param extras [Hash{Symbol => Object}] the output choices, filled in as flags parse
52
+ # @return [OptionParser] the parser
53
+ def parser(values, extras)
54
+ OptionParser.new do |opts|
55
+ opts.banner = "usage: bin/bench [options]"
56
+ opts.on("--domain NAMES", Array, "pizzas, banking (default: both)") { |v| values[:domains] = v }
57
+ opts.on("--targets NAMES", Array, "#{Suite::TARGETS.join(', ')} (default: all)") { |v| values[:targets] = v }
58
+ opts.on("--iterations N", Integer, "timed cycles per run (default #{DEFAULTS[:iterations]})") do |v|
59
+ values[:iterations] = v
60
+ end
61
+ opts.on("--warmup N", Integer, "discarded cycles before timing (default #{DEFAULTS[:warmup]})") do |v|
62
+ values[:warmup] = v
63
+ end
64
+ opts.on("--runs N", Integer, "fresh boots per target, median reported (default #{DEFAULTS[:runs]})") do |v|
65
+ values[:runs] = v
66
+ end
67
+ opts.on("--rust-binary PATH", "use this `rust` binary instead of building one") { |v| extras[:rust_binary] = v }
68
+ opts.on("--format FORMAT", %w[markdown json], "markdown (default) or json") { |v| extras[:format] = v }
69
+ opts.on("--output PATH", "also write the full JSON result here") { |v| extras[:output] = v }
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,100 @@
1
+ require "etc"
2
+ require "open3"
3
+
4
+ module Hecks
5
+ module Bench
6
+ # A description of the machine and toolchain a benchmark ran on.
7
+ #
8
+ # A number without its hardware is not reproducible, so every report carries
9
+ # this. Each probe is best effort: a missing tool becomes `"unknown"` rather
10
+ # than an error, since the benchmark itself is still valid without it.
11
+ module Environment
12
+ module_function
13
+
14
+ # Collects the machine and toolchain facts a report should carry.
15
+ #
16
+ # @return [Hash{Symbol => Object}] `:cpu`, `:cores`, `:memory_gib`, `:os`, `:ruby`,
17
+ # `:yjit`, `:rustc`, `:cargo`, `:hecks`, `:commit` and `:load_average`
18
+ def describe
19
+ {
20
+ cpu: cpu, cores: Etc.nprocessors, memory_gib: memory_gib, os: os,
21
+ ruby: RUBY_DESCRIPTION, yjit: yjit?,
22
+ rustc: capture("rustc", "-V"), cargo: capture("cargo", "-V"),
23
+ hecks: Hecks::VERSION, commit: commit, load_average: load_average
24
+ }
25
+ end
26
+
27
+ # Reads the one-minute load average, the quickest sign that something else was
28
+ # busy while a benchmark ran. `Suite.call` reads it before and after and reports both.
29
+ #
30
+ # @return [Float, nil] the one-minute load average, or nil when it cannot be read
31
+ def load_average
32
+ text = RUBY_PLATFORM.include?("darwin") ? capture("sysctl", "-n", "vm.loadavg").delete("{}") : File.read("/proc/loadavg")
33
+ text.split.first.to_f.round(2)
34
+ rescue SystemCallError
35
+ nil
36
+ end
37
+
38
+ # Names the processor.
39
+ #
40
+ # @return [String] the CPU model, or `"unknown"`
41
+ def cpu
42
+ return capture("sysctl", "-n", "machdep.cpu.brand_string") if RUBY_PLATFORM.include?("darwin")
43
+
44
+ model = File.foreach("/proc/cpuinfo").find { |line| line.start_with?("model name") }
45
+ model ? model.split(":", 2).last.strip : "unknown"
46
+ rescue SystemCallError
47
+ "unknown"
48
+ end
49
+
50
+ # Reads installed memory.
51
+ #
52
+ # @return [Float, String] physical memory in GiB, or `"unknown"`
53
+ def memory_gib
54
+ bytes =
55
+ if RUBY_PLATFORM.include?("darwin")
56
+ capture("sysctl", "-n", "hw.memsize").to_i
57
+ else
58
+ File.read("/proc/meminfo")[/MemTotal:\s+(\d+)/, 1].to_i * 1024
59
+ end
60
+ bytes.zero? ? "unknown" : (bytes / (1024.0**3)).round(1)
61
+ rescue SystemCallError
62
+ "unknown"
63
+ end
64
+
65
+ # Names the operating system.
66
+ #
67
+ # @return [String] the OS name and version, or `"unknown"`
68
+ def os
69
+ return "macOS #{capture('sw_vers', '-productVersion')} (#{capture('uname', '-m')})" if RUBY_PLATFORM.include?("darwin")
70
+
71
+ capture("uname", "-sr")
72
+ end
73
+
74
+ # Says whether Ruby's `YJIT` compiler was running.
75
+ #
76
+ # @return [Boolean] true when `YJIT` is enabled in this process
77
+ def yjit?
78
+ defined?(RubyVM::YJIT) ? RubyVM::YJIT.enabled? : false
79
+ end
80
+
81
+ # Names the Hecks source revision under test.
82
+ #
83
+ # @return [String] the short commit hash, or `"unknown"` outside a git checkout
84
+ def commit
85
+ capture("git", "-C", File.expand_path("../../..", __dir__), "rev-parse", "--short", "HEAD")
86
+ end
87
+
88
+ # Runs a command and returns its first line of output.
89
+ #
90
+ # @param command [Array<String>] the program and its arguments
91
+ # @return [String] the trimmed first line of stdout, or `"unknown"` if it cannot run
92
+ def capture(*command)
93
+ out, status = Open3.capture2(*command, err: File::NULL)
94
+ status.success? && !out.strip.empty? ? out.lines.first.strip : "unknown"
95
+ rescue SystemCallError
96
+ "unknown"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,41 @@
1
+ module Hecks
2
+ module Bench
3
+ # Decides whether the Postgres targets can run at all.
4
+ #
5
+ # Postgres is optional. A machine with no server, or without the `pg` gem, gets a
6
+ # skipped target and a sentence saying why, never a stack trace. Connection
7
+ # settings come from libpq's own environment (`PGHOST`, `PGPORT`, `PGUSER`,
8
+ # `PGPASSWORD`), the same way the fuzzer's Postgres adapters read them.
9
+ module PostgresProbe
10
+ module_function
11
+
12
+ # Tries to reach the server's maintenance database.
13
+ #
14
+ # @return [String, nil] nil when a server answered, otherwise one sentence saying why
15
+ # the Postgres targets cannot run
16
+ def unavailable_reason
17
+ require "pg"
18
+ PG.connect(dbname: "postgres", connect_timeout: 2).close
19
+ nil
20
+ rescue LoadError
21
+ "the `pg` gem is not installed"
22
+ rescue PG::Error => e
23
+ "no Postgres server is reachable (#{e.message.lines.first.to_s.strip}); start one, or point " \
24
+ "PGHOST, PGPORT and PGUSER at one"
25
+ end
26
+
27
+ # Reads the server's version for the report's environment block.
28
+ #
29
+ # @return [String] the `server_version` setting, or `"unknown"` if it cannot be read
30
+ def server_version
31
+ require "pg"
32
+ connection = PG.connect(dbname: "postgres", connect_timeout: 2)
33
+ connection.exec("SHOW server_version").getvalue(0, 0)
34
+ rescue LoadError, PG::Error
35
+ "unknown"
36
+ ensure
37
+ connection&.close
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,116 @@
1
+ require "json"
2
+
3
+ module Hecks
4
+ module Bench
5
+ # Renders a `Suite.call` result as Markdown or JSON.
6
+ #
7
+ # The Markdown is what `docs/benchmarks.md` pastes in. It states the machine, the
8
+ # counts and the skipped targets next to the table, so a copied table is never a
9
+ # bare number.
10
+ module Report
11
+ module_function
12
+
13
+ # Renders the whole report as Markdown.
14
+ #
15
+ # @param result [Hash] the value `Suite.call` returned
16
+ # @return [String] the environment, the configuration, the table and any skipped targets
17
+ def markdown(result)
18
+ [environment_block(result[:environment]), configuration_block(result[:config]),
19
+ table(result[:results]), floor_block(result[:results]), skipped_block(result[:skipped])]
20
+ .compact.join("\n\n") << "\n"
21
+ end
22
+
23
+ # Renders the whole report as JSON.
24
+ #
25
+ # @param result [Hash] the value `Suite.call` returned
26
+ # @return [String] pretty-printed JSON of `result`
27
+ def json(result)
28
+ JSON.pretty_generate(result)
29
+ end
30
+
31
+ # Renders the results table.
32
+ #
33
+ # @param results [Array<Hash>] the `:results` of `Suite.call`
34
+ # @return [String] a Markdown table with one row per domain and target
35
+ def table(results)
36
+ header = ["| domain | target | commands/s | range across runs | p50 (us) | p99 (us) | drift |",
37
+ "|---|---|---:|---:|---:|---:|---:|"]
38
+ (header + results.map { |entry| row(entry) }).join("\n")
39
+ end
40
+
41
+ # Renders one table row.
42
+ #
43
+ # @param entry [Hash] one aggregated result
44
+ # @return [String] the row as Markdown
45
+ def row(entry)
46
+ median = entry[:median]
47
+ low, high = entry[:throughput_range]
48
+ cells = [entry[:domain], entry[:target], number(median[:throughput_per_s], 0),
49
+ "#{number(low, 0)} to #{number(high, 0)}", number(median[:p50_us], 1),
50
+ number(median[:p99_us], 1), number(median[:drift], 2)]
51
+ "| #{cells.join(' | ')} |"
52
+ end
53
+
54
+ # Formats a number with thousands separators.
55
+ #
56
+ # @param value [Numeric, nil] the number
57
+ # @param places [Integer] digits after the decimal point
58
+ # @return [String] the formatted number, or `"n/a"` for nil
59
+ def number(value, places)
60
+ return "n/a" if value.nil?
61
+
62
+ whole, fraction = format("%.#{places}f", value).split(".")
63
+ [whole.gsub(/(\d)(?=(\d{3})+\z)/, '\1,'), fraction].compact.join(".")
64
+ end
65
+
66
+ # Renders the machine and toolchain.
67
+ #
68
+ # @param environment [Hash] the `:environment` of `Suite.call`
69
+ # @return [String] a Markdown bullet list
70
+ def environment_block(environment)
71
+ load = environment[:load_average]
72
+ lines = [
73
+ "- CPU: #{environment[:cpu]} (#{environment[:cores]} cores), #{environment[:memory_gib]} GiB",
74
+ "- OS: #{environment[:os]}",
75
+ "- Ruby: #{environment[:ruby]} (YJIT #{environment[:yjit] ? 'on' : 'off'})",
76
+ "- Rust: #{environment[:rustc]}; #{environment[:cargo]}",
77
+ "- Hecks: #{environment[:hecks]} at #{environment[:commit]}",
78
+ "- Load average (1 min) before and after: #{load[:before]} and #{load[:after]}"
79
+ ]
80
+ lines << "- Postgres: #{environment[:postgres]}" if environment[:postgres]
81
+ lines.join("\n")
82
+ end
83
+
84
+ # Renders how hard the run pushed.
85
+ #
86
+ # @param config [Hash] the `:config` of `Suite.call`
87
+ # @return [String] one sentence with the warmup, iteration and run counts
88
+ def configuration_block(config)
89
+ "Warmup #{config[:warmup]} cycles, then #{config[:iterations]} timed cycles per run, " \
90
+ "median of #{config[:runs]} run(s), domains: #{config[:domains].join(', ')}."
91
+ end
92
+
93
+ # Renders the Rust round-trip floor, if a Rust target ran.
94
+ #
95
+ # @param results [Array<Hash>] the `:results` of `Suite.call`
96
+ # @return [String, nil] a sentence naming each domain's floor, or nil without a Rust result
97
+ def floor_block(results)
98
+ floors = results.select { |entry| entry[:target] == "rust" }
99
+ .map { |entry| "#{entry[:domain]} #{number(entry[:median][:roundtrip_floor_p50_us], 1)} us" }
100
+ return nil if floors.empty?
101
+
102
+ "Rust pipe round-trip floor (p50, a step refused at once): #{floors.join(', ')}."
103
+ end
104
+
105
+ # Renders the targets that did not run.
106
+ #
107
+ # @param skipped [Array<Hash>] the `:skipped` of `Suite.call`
108
+ # @return [String, nil] one line per skipped target, or nil when none were skipped
109
+ def skipped_block(skipped)
110
+ return nil if skipped.empty?
111
+
112
+ (["Skipped:"] + skipped.map { |entry| "- #{entry[:target]}: #{entry[:reason]}" }).join("\n")
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,151 @@
1
+ require_relative "run"
2
+
3
+ module Hecks
4
+ module Bench
5
+ # Measures the Ruby runtime, in-process, on one persistence adapter.
6
+ #
7
+ # The domain is booted from a throwaway copy through `Fuzzing::IsolatedBoot`, so a
8
+ # run always starts from an empty store and never touches the example's own data.
9
+ # That is the same isolation `bin/fuzz --adapter` uses. Both Postgres adapters write
10
+ # to a uniquely named schema in the `hecks_bench` database, which is dropped after the
11
+ # run, so a concurrent `bin/fuzz` and its shared `hecks_fuzz` schema are never touched.
12
+ #
13
+ # Only `dispatch_flat` is inside the timer. Boot, setup commands and warmup are not.
14
+ module RubyRunner
15
+ # The adapters this runner can bind, as `IsolatedBoot` names them.
16
+ ADAPTERS = %i[memory sqlite postgres postgres_era].freeze
17
+
18
+ # The adapters that need a server and a scratch schema.
19
+ POSTGRES_ADAPTERS = %i[postgres postgres_era].freeze
20
+
21
+ # The scratch database both Postgres adapters run in.
22
+ DATABASE = "hecks_bench".freeze
23
+
24
+ module_function
25
+
26
+ # Boots the workload's domain on `adapter` and times its commands.
27
+ #
28
+ # @param workload [Workload] the commands to dispatch
29
+ # @param adapter [Symbol] one of `ADAPTERS`
30
+ # @param warmup [Integer] cycles dispatched and discarded before timing starts
31
+ # @param iterations [Integer] cycles timed
32
+ # @return [Run] the timings of the measured cycles
33
+ # @raise [ArgumentError] if `adapter` is not one of `ADAPTERS`
34
+ # @raise [StandardError] whatever refusal a command raises, warmup included, since a
35
+ # benchmark that swallowed one would be timing something other than its workload
36
+ def call(workload, adapter:, warmup:, iterations:)
37
+ unless ADAPTERS.include?(adapter)
38
+ raise ArgumentError, "unknown adapter #{adapter.inspect} — one of #{ADAPTERS.join(', ')}"
39
+ end
40
+
41
+ load_dependencies
42
+ schema = "bench_#{Process.pid}_#{rand(1 << 32).to_s(16)}"
43
+ quietly_for_postgres do
44
+ boot(workload, adapter, schema) do |runtime|
45
+ workload.setup.each { |step| dispatch(runtime, step) }
46
+ measure(runtime, workload, warmup: warmup, iterations: iterations)
47
+ end
48
+ end
49
+ ensure
50
+ drop_schema(schema) if schema && POSTGRES_ADAPTERS.include?(adapter)
51
+ end
52
+
53
+ # Boots the workload's domain on `adapter` in an isolated copy.
54
+ #
55
+ # @param workload [Workload] the domain to boot
56
+ # @param adapter [Symbol] one of `ADAPTERS`
57
+ # @param schema [String] the scratch schema name, used only by the Postgres adapters
58
+ # @yield [Hecks::Runtime] the booted domain
59
+ # @return [Object] whatever the block returns
60
+ def boot(workload, adapter, schema)
61
+ Hecks::Fuzzing::IsolatedBoot.call(workload.domain_path, adapter: adapter, database: DATABASE,
62
+ schema: schema, scratch: { database: DATABASE, schema: schema }) do |copy|
63
+ yield Hecks.boot(copy, install_facade: false)
64
+ end
65
+ end
66
+
67
+ # Keeps Postgres's `schema already exists` notices off stderr while a block runs.
68
+ #
69
+ # Every aggregate opens its own connection and each one repeats the notice, which
70
+ # buries the progress lines. The server setting is restored afterwards.
71
+ #
72
+ # @yield the work to run with notices quieted
73
+ # @return [Object] whatever the block returns
74
+ def quietly_for_postgres
75
+ previous = ENV.fetch("PGOPTIONS", nil)
76
+ ENV["PGOPTIONS"] = [previous, "-c client_min_messages=warning"].compact.join(" ")
77
+ yield
78
+ ensure
79
+ ENV["PGOPTIONS"] = previous
80
+ end
81
+
82
+ # Dispatches warmup cycles untimed, then times the measured ones.
83
+ #
84
+ # @param runtime [Hecks::Runtime] the booted domain
85
+ # @param workload [Workload] the commands to dispatch
86
+ # @param warmup [Integer] cycles to discard
87
+ # @param iterations [Integer] cycles to time
88
+ # @return [Run] the timings of the measured cycles
89
+ def measure(runtime, workload, warmup:, iterations:)
90
+ warmup.times { |n| workload.cycle(n).each { |step| dispatch(runtime, step) } }
91
+ GC.start
92
+ samples = []
93
+ started = now
94
+ iterations.times do |n|
95
+ workload.cycle(warmup + n).each { |step| samples << [step.verb, time { dispatch(runtime, step) }] }
96
+ end
97
+ Run.new(samples: samples, wall_seconds: now - started)
98
+ end
99
+
100
+ # Sends one step to the runtime.
101
+ #
102
+ # @param runtime [Hecks::Runtime] the booted domain
103
+ # @param step [Workload::Step] the command to dispatch
104
+ # @return [Object] whatever `dispatch_flat` answers
105
+ def dispatch(runtime, step)
106
+ runtime.dispatch_flat(step.verb, step.ruby_args)
107
+ end
108
+
109
+ # Reads the monotonic clock.
110
+ #
111
+ # @return [Float] seconds on the monotonic clock
112
+ def now
113
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
114
+ end
115
+
116
+ # Times a block.
117
+ #
118
+ # @yield the work to time
119
+ # @return [Float] seconds the block took
120
+ def time
121
+ started = now
122
+ yield
123
+ now - started
124
+ end
125
+
126
+ # Loads what booting a domain on every adapter needs.
127
+ #
128
+ # @return [void]
129
+ def load_dependencies
130
+ require "hecks"
131
+ require "hecks/fuzzing"
132
+ require "hecks/ports/persistence/plugins/era"
133
+ end
134
+
135
+ # Removes the schema a Postgres run created, so repeated runs do not accumulate.
136
+ #
137
+ # @param schema [String] the schema name this run used
138
+ # @return [void]
139
+ def drop_schema(schema)
140
+ require "pg"
141
+ connection = PG.connect(dbname: DATABASE, connect_timeout: 2)
142
+ connection.exec("SET client_min_messages = warning")
143
+ connection.exec("DROP SCHEMA IF EXISTS #{connection.quote_ident(schema)} CASCADE")
144
+ rescue LoadError, PG::Error
145
+ nil
146
+ ensure
147
+ connection&.close
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,55 @@
1
+ module Hecks
2
+ module Bench
3
+ # The raw timings of one measured pass over a workload on one runtime.
4
+ #
5
+ # Runners produce these and know nothing about reporting. {#summary} is the only
6
+ # place raw samples become the figures a report prints.
7
+ class Run
8
+ # @return [Array<Array(String, Float)>] each timed command's verb and latency in
9
+ # seconds, in dispatch order
10
+ attr_reader :samples
11
+
12
+ # @return [Float] seconds from the first timed command's start to the last one's end
13
+ attr_reader :wall_seconds
14
+
15
+ # @return [Hash{Symbol => Object}] facts a runner wants reported alongside the timings
16
+ attr_reader :extras
17
+
18
+ # Builds a run.
19
+ #
20
+ # @param samples [Array<Array(String, Float)>] verb and latency seconds per command
21
+ # @param wall_seconds [Float] the wall-clock duration of the whole timed pass
22
+ # @param extras [Hash{Symbol => Object}] runner-specific facts, such as a round-trip floor
23
+ def initialize(samples:, wall_seconds:, extras: {})
24
+ @samples = samples
25
+ @wall_seconds = wall_seconds
26
+ @extras = extras
27
+ end
28
+
29
+ # Reduces the samples to the figures a report prints.
30
+ #
31
+ # @return [Hash{Symbol => Object}] `:commands`, `:wall_seconds`, `:throughput_per_s`,
32
+ # the `Stats.latency` figures, `:drift`, `:by_verb` (each verb's count, p50 and p99),
33
+ # and any `extras`
34
+ def summary
35
+ seconds = samples.map(&:last)
36
+ {
37
+ commands: samples.size,
38
+ wall_seconds: wall_seconds.round(4),
39
+ throughput_per_s: (samples.size / wall_seconds).round(1),
40
+ **Stats.latency(seconds),
41
+ drift: Stats.drift(seconds),
42
+ by_verb: by_verb
43
+ }.merge(extras)
44
+ end
45
+
46
+ private
47
+
48
+ def by_verb
49
+ samples.group_by(&:first).transform_values do |group|
50
+ Stats.latency(group.map(&:last)).slice(:count, :p50_us, :p99_us)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end