module Benchmark # Benchmark IPS calculates the number of iterations per second for a given # block of code. The strategy is to use two stages: a warmup stage and a # calculation stage. # # The warmup phase defaults to 2 seconds. During this stage we figure out how # many cycles are needed to run the block for roughly 100ms, and record it. # # The calculation defaults to 5 seconds. During this stage we run the block # in sets of the size calculated in the warmup stage. The measurements for # those sets are then used to calculate the mean and standard deviation, # which are then reported. Additionally we compare the means to that of the # fastest. module IPS class Job # List of all entries in the benchmark. # After `#execute`, these are populated with the resulting statistics. property items : Array(Entry) @warmup_time : Time::Span @calculation_time : Time::Span def initialize(calculation @calculation_time : Time::Span = 5.seconds, warmup @warmup_time : Time::Span = 2.seconds, interactive : Bool = STDOUT.tty?) @interactive = !!interactive @items = [] of Entry end @[Deprecated("Use `.new(Time::Span, Time::Span, Bool)` instead.")] def self.new(calculation = 5, warmup = 2, interactive = STDOUT.tty?) new(calculation.seconds, warmup.seconds, !!interactive) end # Adds code to be benchmarked def report(label : String = "", &action : ->) : Benchmark::IPS::Entry item = Entry.new(label, action) @items << item item end def execute : Nil run_warmup run_calculation run_comparison end def report : Nil max_label = ran_items.max_of &.label.size max_compare = ran_items.max_of &.human_compare.size max_bytes_per_op = ran_items.max_of &.bytes_per_op.humanize(base: 1024).size ran_items.each do |item| printf "%s %s (%s) (±%5.2f%%) %sB/op %s\n", item.label.rjust(max_label), item.human_mean, item.human_iteration_time, item.relative_stddev, item.bytes_per_op.humanize(base: 1024).rjust(max_bytes_per_op), item.human_compare.rjust(max_compare) end end # The warmup stage gathers information about the items that is later used # in the calculation stage private def run_warmup @items.each do |item| GC.collect count = 0_u64 elapsed = Time.measure do target = Time.instant + @warmup_time while Time.instant < target item.call count += 1 end end item.set_cycles(elapsed, count) end end private def run_calculation @items.each do |item| GC.collect measurements = [] of Time::Span bytes = 0_i64 cycles = 0_i64 target = Time.instant + @calculation_time loop do elapsed = nil bytes_taken = Benchmark.memory do elapsed = Time.measure { item.call_for_100ms } end bytes += bytes_taken cycles += item.cycles measurements << elapsed.not_nil! break if Time.instant >= target end ips = measurements.map { |m| item.cycles.to_f / m.total_seconds } item.calculate_stats(ips) item.bytes_per_op = (bytes.to_f / cycles.to_f).round.to_u64 if @interactive run_comparison report print "\e[#{ran_items.size}A" end end end private def ran_items @items.select(&.ran?) end private def run_comparison fastest = ran_items.max_by(&.mean) ran_items.each do |item| item.slower = (fastest.mean / item.mean).to_f end end end class Entry # Label of the benchmark property label : String # Code to be benchmarked property action : -> # Number of cycles needed to run `action` for approximately 100ms. # Calculated during the warmup stage property! cycles : Int32 # Number of 100ms runs during the calculation stage property! size : Int32 # Statistical mean from calculation stage property! mean : Float64 # Statistical variance from calculation stage property! variance : Float64 # Statistical standard deviation from calculation stage property! stddev : Float64 # Relative standard deviation as a percentage property! relative_stddev : Float64 # Multiple slower than the fastest entry property! slower : Float64 # Number of bytes allocated per operation property! bytes_per_op : UInt64 @ran : Bool @ran = false def initialize(@label : String, @action : ->) end def ran? : Bool @ran end def call : Nil action.call end def call_for_100ms : Nil cycles.times { action.call } end def set_cycles(duration, iterations) : Nil @cycles = (iterations / duration.total_milliseconds * 100).to_i @cycles = 1 if cycles <= 0 end def calculate_stats(samples) : Nil @ran = true @size = samples.size @mean = samples.sum.to_f / size.to_f @variance = (samples.reduce(0) { |acc, i| acc + ((i - mean) ** 2) }).to_f / size.to_f @stddev = Math.sqrt(variance) @relative_stddev = 100.0 * (stddev / mean) end def human_mean : String mean.humanize(precision: 2, significant: false, prefixes: Number::SI_PREFIXES_PADDED).rjust(7) end def human_iteration_time : String iteration_time = 1.0 / mean iteration_time.humanize(precision: 2, significant: false) do |magnitude, _| magnitude = Number.prefix_index(magnitude).clamp(-9..0) {magnitude, magnitude == 0 ? "s " : "#{Number.si_prefix(magnitude)}s"} end.rjust(8) end def human_compare : String if slower == 1.0 "fastest" else sprintf "%5.2f× slower", slower end end end end end