perfer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (C) 2012 Benoit Daloze
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
@@ -0,0 +1,33 @@
1
+ # Perfer - A benchmark tool for all rubies!
2
+
3
+ <!-- usage -->
4
+ ```text
5
+ Usage:
6
+ perfer <command> [options] arguments
7
+
8
+ Commands:
9
+ run files+ - run with current ruby
10
+ report files+ - show the results
11
+ config reset - reset the configuration file to the defaults (or create it)
12
+ help - show this help
13
+ results
14
+ path files+ - show the paths to the result files
15
+ rm,delete files+ - remove the result files
16
+
17
+ <files+> are a set of benchmark files
18
+
19
+ Common options:
20
+ -t TIME Minimal time to run (greater usually improve accuracy)
21
+ -m N Numbers of measurements per job
22
+ -v Verbose
23
+ -h, --help Show this help
24
+ ```
25
+ <!-- usage -->
26
+
27
+ See [The GSoC proposal](http://www.google-melange.com/gsoc/proposal/review/google/gsoc2012/eregon/1) for more details.
28
+
29
+ And the [Wiki](https://github.com/jruby/perfer/wiki).
30
+
31
+ ## Current status
32
+
33
+ This is a work in progress, 0.1.0 is a preview release.
@@ -0,0 +1,54 @@
1
+ require 'yaml'
2
+ require 'path'
3
+ require 'optparse'
4
+ require 'hitimes'
5
+ require 'forwardable'
6
+ require 'ffi'
7
+ require 'digest/sha1'
8
+ require 'backports/1.9'
9
+
10
+ Path.require_tree('perfer', :except => %w[platform/])
11
+
12
+ module Perfer
13
+ DIR = Path('~/.perfer')
14
+ TIMES_FIELDS = [:real, :utime, :stime, :cutime, :cstime].freeze
15
+
16
+ class << self
17
+ attr_reader :sessions, :configuration
18
+
19
+ def reset
20
+ @configuration = Configuration.new
21
+ @sessions = []
22
+ end
23
+
24
+ def session(name = nil, &block)
25
+ Session.new(Path.file(caller), name, &block)
26
+ end
27
+
28
+ # Shortcut for Perfer.session { |s| s.iterate ... }
29
+ def iterate(title = nil, *args, &block)
30
+ Session.new(Path.file(caller)) { |session|
31
+ title ||= session.name
32
+ session.iterate(title, *args, &block)
33
+ }
34
+ end
35
+
36
+ def measure(&block)
37
+ times_before = Process.times
38
+ real = Hitimes::Interval.measure(&block)
39
+ times = Process.times
40
+
41
+ data = { :real => real }
42
+ times.members.each { |field|
43
+ # precision of times(3) or getrusage(2) is no more than 1e-6
44
+ value = (times[field] - times_before[field]).round(6)
45
+ if value != 0.0 # do not keep these if they measured nothing
46
+ data[field.to_sym] = value
47
+ end
48
+ }
49
+ data
50
+ end
51
+ end
52
+
53
+ Perfer.reset
54
+ end
@@ -0,0 +1,139 @@
1
+ module Perfer
2
+ class CLI
3
+ COMMANDS = %w[
4
+ config
5
+ help
6
+ report
7
+ results
8
+ run
9
+ ]
10
+
11
+ HELP = <<-EOS
12
+ Usage:
13
+ perfer <command> [options] arguments
14
+
15
+ Commands:
16
+ run files+ - run with current ruby
17
+ report files+ - show the results
18
+ config reset - reset the configuration file to the defaults (or create it)
19
+ help - show this help
20
+ results
21
+ path files+ - show the paths to the result files
22
+ rm,delete files+ - remove the result files
23
+
24
+ <files+> are a set of benchmark files
25
+
26
+ Common options:
27
+ EOS
28
+
29
+ def initialize(argv)
30
+ @argv = argv
31
+
32
+ @opts = OptionParser.new do |options|
33
+ options.banner = HELP
34
+ common_options(options)
35
+ end
36
+ end
37
+
38
+ def execute
39
+ begin
40
+ @opts.order!(@argv)
41
+ rescue OptionParser::ParseError => e
42
+ error e.message
43
+ end
44
+
45
+ @command = @argv.shift
46
+ error "A command must be given, one of: #{COMMANDS*', '}" unless @command
47
+ error "Unknown command: #{@command.inspect}" unless COMMANDS.include? @command
48
+
49
+ send(@command)
50
+ end
51
+
52
+ def unknown_subcommand(subcommand)
53
+ if subcommand
54
+ error "Unknown subcommand for #{@command}: #{subcommand.inspect}"
55
+ else
56
+ error "`perfer #{@command}` needs a subcommand"
57
+ end
58
+ end
59
+
60
+ def error message
61
+ $stderr.puts message
62
+ $stderr.puts
63
+ abort @opts.help
64
+ end
65
+
66
+ def help
67
+ puts @opts.help
68
+ end
69
+
70
+ def report
71
+ measurements = (@argv.shift if @argv.first == '--measurements')
72
+ each_session { |session| session.report_results(:measurements => measurements) }
73
+ end
74
+
75
+ def run
76
+ # load files
77
+ files.each do |file|
78
+ require file.path
79
+ end
80
+ Perfer.sessions.each(&:run)
81
+ end
82
+
83
+ def results
84
+ case subcommand = @argv.shift
85
+ when "path"
86
+ each_session { |session| puts session.store.file }
87
+ when "delete", "rm"
88
+ each_session { |session| session.store.delete }
89
+ when "rewrite"
90
+ each_session { |session| session.store.rewrite }
91
+ else
92
+ unknown_subcommand subcommand
93
+ end
94
+ end
95
+
96
+ def config
97
+ case subcommand = @argv.shift
98
+ when "reset"
99
+ Perfer.configuration.write_defaults
100
+ else
101
+ unknown_subcommand subcommand
102
+ end
103
+ end
104
+
105
+ def common_options(options)
106
+ options.on('-t TIME', Float, "Minimal time to run (greater usually improve accuracy)") do |t|
107
+ error "Minimal time must be > 0" if t <= 0
108
+ Perfer.configuration.minimal_time = t
109
+ end
110
+ options.on('-m N', Integer, "Numbers of measurements per job") do |n|
111
+ error "There must be at least 2 measurements" if n < 2
112
+ Perfer.configuration.measurements = n
113
+ end
114
+ options.on('-v', "Verbose") do
115
+ Perfer.configuration.verbose = true
116
+ end
117
+ options.on('-h', '--help', "Show this help") do
118
+ puts options.help
119
+ exit
120
+ end
121
+ end
122
+
123
+ private
124
+ def files
125
+ @argv.map { |file| Path(file).expand }
126
+ end
127
+
128
+ def load_from_files
129
+ files.each do |file|
130
+ Session.new(file)
131
+ end
132
+ end
133
+
134
+ def each_session(&block)
135
+ load_from_files
136
+ Perfer.sessions.each(&block)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,42 @@
1
+ module Perfer
2
+ class Configuration
3
+ DEFAULTS = {
4
+ :minimal_time => 1.0,
5
+ :measurements => 10,
6
+ :verbose => false,
7
+ }.freeze
8
+
9
+ PROPERTIES = DEFAULTS.keys
10
+
11
+ PROPERTIES.each { |property| attr_accessor property }
12
+
13
+ def initialize
14
+ @config_file = DIR/'config.yml'
15
+
16
+ DEFAULTS.each_pair { |property, value|
17
+ instance_variable_set(:"@#{property}", value)
18
+ }
19
+
20
+ if @config_file.exist? and !@config_file.empty?
21
+ YAML.load_file(@config_file).each_pair { |property, value|
22
+ property = property.to_sym
23
+ if PROPERTIES.include? property
24
+ instance_variable_set(:"@#{property}", value)
25
+ else
26
+ warn "Unknown property in configuration file: #{property}"
27
+ end
28
+ }
29
+ end
30
+ end
31
+
32
+ def write_defaults
33
+ @config_file.write YAML.dump DEFAULTS
34
+ end
35
+
36
+ def to_hash
37
+ PROPERTIES.each_with_object({}) { |property, h|
38
+ h[property] = instance_variable_get(:"@#{property}")
39
+ }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,11 @@
1
+ module Perfer
2
+ class Error < RuntimeError
3
+ end
4
+
5
+ module Errors
6
+ MIX_BENCH_TYPES = "Cannot mix iterations and input size benchmarks in the same session"
7
+ SAME_JOB_TITLES = "Multiple jobs with the same title are not allowed"
8
+ WRONG_MEASURE_USE = "Cannot use Session#measure with an iteration job"
9
+ WORKING_DIR_DIRTY = "The working directory is dirty, commit (or stash) your changes first"
10
+ end
11
+ end
@@ -0,0 +1,73 @@
1
+ # encoding: utf-8
2
+
3
+ module Perfer
4
+ module Formatter
5
+ extend self
6
+
7
+ TIME_UNITS = {
8
+ 0 => "s ",
9
+ -3 => "ms",
10
+ -6 => "µs",
11
+ -9 => "ns"
12
+ }
13
+
14
+ def max_length_of(enum)
15
+ max = 0
16
+ enum.each { |e|
17
+ alt = yield(e).to_s.length
18
+ max = alt if alt > max
19
+ }
20
+ max
21
+ end
22
+
23
+ def format_ips(ips)
24
+ if ips > 100
25
+ ips.round
26
+ else
27
+ "%.3g" % ips
28
+ end
29
+ end
30
+
31
+ def format_n(n, maxlen)
32
+ n.to_s.rjust(maxlen)
33
+ end
34
+
35
+ def format_float(f)
36
+ ('%5.3f' % f)[0...5]
37
+ end
38
+
39
+ def format_error(error, base, scale)
40
+ "#{format_float(error*10**-scale)} (#{'%4.1f' % (error / base * 100.0)}%)"
41
+ end
42
+
43
+ def format_time(time)
44
+ time.strftime("%F %T")
45
+ end
46
+
47
+ def float_scale(time)
48
+ if time == 0 or time > 1.0
49
+ 0
50
+ elsif time > 0.001
51
+ -3
52
+ elsif time > 0.000001
53
+ -6
54
+ else
55
+ -9
56
+ end
57
+ end
58
+
59
+ # formats a duration with an 8-chars width
60
+ def format_duration(time, scale = float_scale(time))
61
+ if time == 0
62
+ " 0 "
63
+ else
64
+ "#{format_float(time*10**-scale)} #{TIME_UNITS[scale]}"
65
+ end
66
+ end
67
+
68
+ def format_duration_and_error(time, error, after_unit = "")
69
+ scale = float_scale(time)
70
+ "#{format_duration(time, scale)}#{after_unit} ± #{format_error(error, time, scale)}"
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,22 @@
1
+ module Perfer
2
+ class MeasurementsFormatter
3
+ include Formatter
4
+
5
+ SEPARATOR = ' '
6
+
7
+ def initialize(measurements)
8
+ @measurements = measurements
9
+ @fields = Perfer::TIMES_FIELDS.dup
10
+ @fields.reject! { |field|
11
+ measurements.none? { |m| m.key? field }
12
+ }
13
+ end
14
+
15
+ def report
16
+ puts @fields.map { |field| field.to_s.center(8) }.join(SEPARATOR).rstrip
17
+ @measurements.each { |m|
18
+ puts @fields.map { |field| format_duration(m[field] || 0) }.join(SEPARATOR)
19
+ }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,43 @@
1
+ module Perfer
2
+ class ResultsFormatter
3
+ include Formatter
4
+
5
+ # maximal job length is computed from +jobs+ if given,
6
+ # or deduced from given results
7
+ def initialize(results, jobs = nil)
8
+ @results = Array(results)
9
+ @max_job_length = if jobs
10
+ max_length_of(jobs, &:title)
11
+ else
12
+ max_length_of(@results) { |r| r[:job] }
13
+ end
14
+ end
15
+
16
+ def job_title(result)
17
+ result[:job].to_s.ljust(@max_job_length)
18
+ end
19
+
20
+ def max_n_length
21
+ @max_n_length ||= max_length_of(@results) { |r| r[:n] }
22
+ end
23
+
24
+ def report(options = {})
25
+ measurements = options[:measurements]
26
+ @results.each do |result|
27
+ MeasurementsFormatter.new(result.data).report if measurements
28
+ r = result
29
+ stats = r.stats
30
+ mean = stats.mean
31
+ error = stats.maximum_absolute_deviation
32
+ if r[:iterations]
33
+ time_per_i, ips = mean/r[:iterations], r[:iterations]/mean
34
+ error /= r[:iterations]
35
+ puts "#{job_title(r)} #{format_duration_and_error time_per_i, error, '/i'} <=> #{format_ips ips} ips"
36
+ else
37
+ n = format_n(r[:n], max_n_length)
38
+ puts "#{job_title(r)} #{n} in #{format_duration_and_error mean, error}"
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,22 @@
1
+ module Perfer
2
+ class SessionFormatter
3
+ include Formatter
4
+
5
+ def initialize(session)
6
+ @session = session
7
+ end
8
+
9
+ def report(options = {})
10
+ return puts "No results available." unless @session.results
11
+ session_name = @session.results.first[:session]
12
+ puts session_name
13
+ @session.results.chunk { |r|
14
+ r[:run_time]
15
+ }.each { |run_time, results|
16
+ puts "Ran at #{format_time run_time} with #{results.first[:ruby]}"
17
+ ResultsFormatter.new(results).report(options)
18
+ puts
19
+ }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,36 @@
1
+ module Perfer
2
+ module Git
3
+ class << self
4
+ def git(command)
5
+ output = `git #{command}`
6
+ output.chomp!
7
+ return nil if output.empty?
8
+ return false if $?.exitstatus != 0
9
+ output
10
+ end
11
+
12
+ def repository?
13
+ git "rev-parse --git-dir 2>#{File::NULL}"
14
+ end
15
+
16
+ def current_branch
17
+ branch = git 'symbolic-ref --quiet HEAD'
18
+ branch = branch[/[^\/]+$/] if branch
19
+ branch
20
+ end
21
+
22
+ def current_commit
23
+ git 'rev-parse --quiet --verify HEAD'
24
+ end
25
+
26
+ def working_directory_clean?
27
+ git('status --porcelain --untracked-files=no') == nil
28
+ end
29
+
30
+ def goto_commit(commit)
31
+ raise Error, Errors::WORKING_DIR_DIRTY unless working_directory_clean?
32
+ git "reset --quiet --hard #{commit}"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,32 @@
1
+ module Perfer
2
+ class Job
3
+ attr_reader :session, :title, :metadata
4
+ def initialize(session, title, &block)
5
+ @session = session
6
+ @title = title
7
+ @block = block
8
+
9
+ @metadata = @session.metadata.merge(:job => @title).freeze
10
+ end
11
+
12
+ def results
13
+ @session.results.select { |result| result[:job] == @title }
14
+ end
15
+
16
+ def minimal_time
17
+ Perfer.configuration.minimal_time
18
+ end
19
+
20
+ def number_of_measurements
21
+ Perfer.configuration.measurements
22
+ end
23
+
24
+ def verbose
25
+ Perfer.configuration.verbose
26
+ end
27
+
28
+ def run
29
+ @session.current_job = self
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ module Perfer
2
+ class InputSizeJob < Job
3
+ attr_writer :last_measurement
4
+
5
+ def start
6
+ 1024
7
+ end
8
+
9
+ def generator(n)
10
+ n * 2
11
+ end
12
+
13
+ def measure(n)
14
+ GC.start
15
+ @block.call(n)
16
+ @last_measurement
17
+ end
18
+
19
+ def run
20
+ super
21
+ n = start
22
+ # find an appropriate maximal n, acts as warm-up
23
+ loop do
24
+ time = measure(n)[:real]
25
+ break if time > minimal_time
26
+ n = generator(n)
27
+ end
28
+
29
+ max = n
30
+ n = start
31
+ loop do
32
+ result = Result.new(@metadata)
33
+ result[:n] = n
34
+ number_of_measurements.times do
35
+ result << measure(n)
36
+ end
37
+ @session.add_result(result)
38
+
39
+ break if n == max
40
+ n = generator(n)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,156 @@
1
+ module Perfer
2
+ class IterationJob < Job
3
+ # This factor ensure some margin,
4
+ # to avoid endlessly changing the number of iterations
5
+ CHANGE_ITERATIONS_MARGIN = 0.1
6
+ UNIQUE_NAME = "a"
7
+
8
+ def repeat_eval
9
+ 100
10
+ end
11
+
12
+ def initialize(session, title, code, data, &block)
13
+ super(session, title, &block)
14
+ if code and !block
15
+ @data = data || {}
16
+ if obj = @data.delete(:self)
17
+ klass = obj.singleton_class
18
+ meth = generate_method_name
19
+ else
20
+ klass = singleton_class
21
+ meth = :measure_call_times_code
22
+ end
23
+
24
+ if klass.method_defined?(meth)
25
+ raise Error, "method #{meth} already defined on #{klass} (#{obj})!"
26
+ end
27
+
28
+ begin
29
+ klass.class_eval <<-EOR
30
+ def #{meth}(__n#{@data.keys.map { |k| ", #{k}" }.join})
31
+ ::Perfer.measure do
32
+ __i = 0
33
+ while __i < __n
34
+ #{"#{code}; " * repeat_eval}
35
+ __i += 1
36
+ end
37
+ end
38
+ end
39
+ EOR
40
+ rescue SyntaxError => e
41
+ raise Error, "There was an error while eval'ing the code: #{code.inspect}\n#{e}"
42
+ end
43
+
44
+ if obj
45
+ singleton_class.send(:define_method, :measure_call_times_code) do |*args|
46
+ obj.send(meth, *args)
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ def generate_method_name
53
+ :"perfer_eval_#{UNIQUE_NAME.succ!}"
54
+ end
55
+
56
+ def measure_call_times(n)
57
+ GC.start
58
+ if !@block
59
+ if n % repeat_eval != 0
60
+ raise Error, "Implementation error: #{n} not multiple of #{repeat_eval}"
61
+ end
62
+ n /= repeat_eval
63
+ measure_call_times_code(n, *@data.values)
64
+ elsif @block.arity == 1
65
+ # give n, the block must iterate n times
66
+ Perfer.measure { @block.call(n) }
67
+ else
68
+ Perfer.measure { n.times(&@block) }
69
+ end.tap { |m| p m if verbose }
70
+ end
71
+
72
+ def round_for_eval(iterations)
73
+ if @block
74
+ iterations
75
+ else
76
+ ((iterations + repeat_eval - 1) / repeat_eval) * repeat_eval
77
+ end
78
+ end
79
+
80
+ def compute_new_iterations(iterations, time)
81
+ (minimal_time * (1.0 + CHANGE_ITERATIONS_MARGIN) * iterations / time).ceil
82
+ end
83
+
84
+ def find_number_of_iterations_required(last_iterations = 1, last_time = 0)
85
+ iterations = last_iterations
86
+ if last_time > 0
87
+ iterations = compute_new_iterations(last_iterations, last_time)
88
+ end
89
+ iterations = round_for_eval(iterations)
90
+ puts "Start search for iterations: start=#{iterations}" if verbose
91
+ loop do
92
+ puts "iterations: #{iterations}" if verbose
93
+ time = measure_call_times(iterations)[:real]
94
+ break if time > minimal_time
95
+
96
+ if time <= 0
97
+ iterations *= 2
98
+ next
99
+ end
100
+
101
+ new_iterations = compute_new_iterations(iterations, time)
102
+ # ensure the number of iterations increases
103
+ if new_iterations <= iterations
104
+ puts "new_iterations <= iterations: #{new_iterations} <= #{iterations}" if verbose
105
+ new_iterations = (iterations*1.5).ceil
106
+ end
107
+ iterations = round_for_eval(new_iterations)
108
+ end
109
+ puts "End search for iterations: iterations=#{iterations}" if verbose
110
+ iterations
111
+ end
112
+
113
+ # median absolute deviation / median
114
+ def mad(measurements)
115
+ stats = Statistics.new(measurements.map { |m| m[:real] })
116
+ mad = stats.median_absolute_deviation
117
+ mad /= stats.median
118
+ puts "mad: #{mad}" if verbose
119
+ mad
120
+ end
121
+
122
+ def run
123
+ super
124
+ measurements = []
125
+
126
+ # Run one iteration, so system-level buffers and other OS warm-up can take place
127
+ # This is usually a very inaccurate measurement, so just discard it
128
+ measure_call_times(round_for_eval(1))
129
+
130
+ iterations = find_number_of_iterations_required
131
+
132
+ measurements_taken = 0
133
+ until measurements.size == number_of_measurements and
134
+ mad(measurements) < 0.01 * measurements_taken / number_of_measurements
135
+ time = measure_call_times(iterations)
136
+ measurements_taken += 1
137
+ if time[:real] < (1.0 - CHANGE_ITERATIONS_MARGIN) * minimal_time
138
+ # restart and find a more appropriate number of iterations
139
+ puts "Restarting, #{time[:real]} < #{minimal_time}" if verbose
140
+ measurements.clear
141
+ measurements_taken = 0
142
+ iterations = find_number_of_iterations_required(iterations, time[:real])
143
+ else
144
+ # circular buffer needed!
145
+ measurements.shift if measurements.size == number_of_measurements
146
+ measurements << time
147
+ end
148
+ end
149
+
150
+ result = Result.new(@metadata)
151
+ result[:iterations] = iterations
152
+ result.data = measurements
153
+ @session.add_result(result)
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,13 @@
1
+ module Perfer
2
+ module Platform
3
+ OS = RbConfig::CONFIG['host_os']
4
+
5
+ if /mingw|mswin/ =~ OS
6
+ require Path.relative('platform/windows')
7
+ extend Windows
8
+ else
9
+ require Path.relative('platform/posix')
10
+ extend POSIX
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,81 @@
1
+ module Perfer::Platform
2
+ module POSIX
3
+ class TimeValStruct < FFI::Struct
4
+ layout :tv_sec, :time_t,
5
+ :tv_usec, :suseconds_t
6
+ end
7
+
8
+ class RUsageStruct < FFI::Struct
9
+ # Rubinius FFI can't handle nested structs
10
+ layout :ru_utime_tv_sec, :time_t, # :ru_utime, TimeValStruct,
11
+ :ru_utime_tv_usec, :suseconds_t,
12
+ :ru_stime_tv_sec, :time_t, # :ru_stime, TimeValStruct,
13
+ :ru_stime_tv_usec, :suseconds_t,
14
+ :ru_maxrss, :long,
15
+ :ru_ixrss, :long,
16
+ :ru_idrss, :long,
17
+ :ru_isrss, :long,
18
+ :ru_minflt, :long,
19
+ :ru_majflt, :long,
20
+ :ru_nswap, :long,
21
+ :ru_inblock, :long,
22
+ :ru_oublock, :long,
23
+ :ru_msgsnd, :long,
24
+ :ru_msgrcv, :long,
25
+ :ru_nsignals, :long,
26
+ :ru_nvcsw, :long,
27
+ :ru_nivcsw, :long
28
+ end
29
+
30
+ module LibC
31
+ extend FFI::Library
32
+ ffi_lib FFI::Library::LIBC
33
+
34
+ RUSAGE_SELF = 0
35
+
36
+ attach_function :getrusage, [:int, :pointer], :int
37
+ end
38
+
39
+ PID = Process.pid
40
+
41
+ def memory_used
42
+ case OS
43
+ when /^darwin/, /^linux/, /^solaris/
44
+ Integer(`ps -o rss= -p #{PID}`) * 1024
45
+ else
46
+ warn "Unknown platform for Platform.command_line: #{os.inspect}"
47
+ nil
48
+ end
49
+ end
50
+
51
+ def maximum_memory_used
52
+ rusage = RUsageStruct.new
53
+ r = LibC.getrusage(LibC::RUSAGE_SELF, rusage)
54
+ if r != 0
55
+ warn "Could not retrieve memory information with getrusage(2)"
56
+ 0
57
+ else
58
+ m = rusage[:ru_maxrss]
59
+ warn "Memory information with getrusage(2) is inaccurate, ru_maxrss was 0" if m == 0
60
+ if /darwin/ =~ OS
61
+ m # reported in bytes
62
+ else
63
+ m * 1024 # reported in KB, as the man page says
64
+ end
65
+ end
66
+ ensure
67
+ rusage.pointer.free if rusage
68
+ end
69
+
70
+ def command_line
71
+ return Rubinius::OS_ARGV.join(' ') if defined? Rubinius::OS_ARGV
72
+ case OS
73
+ when /^darwin/, /^linux/, /^solaris/
74
+ `ps -o args= -p #{PID}`.lines.to_a.last.rstrip
75
+ else
76
+ warn "Unknown platform for Platform.command_line: #{os.inspect}"
77
+ nil
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,83 @@
1
+ module Perfer::Platform
2
+ module Windows
3
+ module Types
4
+ BOOL = :bool
5
+ DWORD = :uint32
6
+ HANDLE = :pointer
7
+ SIZE_T = :size_t
8
+ LPSTR = :string
9
+ end
10
+
11
+ class PProcessMemoryCounters < FFI::Struct
12
+ include Types
13
+ layout :cb, DWORD,
14
+ :PageFaultCount, DWORD,
15
+ :PeakWorkingSetSize, SIZE_T,
16
+ :WorkingSetSize, SIZE_T,
17
+ :QuotaPeakPagedPoolUsage, SIZE_T,
18
+ :QuotaPagedPoolUsage, SIZE_T,
19
+ :QuotaPeakNonPagedPoolUsage, SIZE_T,
20
+ :QuotaNonPagedPoolUsage, SIZE_T,
21
+ :PagefileUsage, SIZE_T,
22
+ :PeakPagefileUsage, SIZE_T
23
+ end
24
+
25
+ module Kernel32
26
+ include Types
27
+ extend FFI::Library
28
+ ffi_lib 'kernel32'
29
+ ffi_convention :stdcall
30
+
31
+ attach_function :GetCurrentProcess, [], HANDLE
32
+ attach_function :GetCommandLineA, [], LPSTR
33
+ end
34
+
35
+ module PSAPI
36
+ include Types
37
+ extend FFI::Library
38
+ ffi_lib 'psapi'
39
+ ffi_convention :stdcall
40
+
41
+ attach_function :GetProcessMemoryInfo, [HANDLE, PProcessMemoryCounters, DWORD], BOOL
42
+ end
43
+
44
+ def get_process_memory_info
45
+ process = Kernel32.GetCurrentProcess
46
+ info = PProcessMemoryCounters.new
47
+ # See http://msdn.microsoft.com/en-us/library/windows/desktop/ms683219%28v=vs.85%29.aspx
48
+ r = PSAPI.GetProcessMemoryInfo(process, info, info.size)
49
+ if !r
50
+ warn "Could not retrieve memory information with GetProcessMemoryInfo()"
51
+ nil
52
+ else
53
+ yield(info)
54
+ end
55
+ ensure
56
+ info.pointer.free if info
57
+ end
58
+ private :get_process_memory_info
59
+
60
+ def memory_used
61
+ get_process_memory_info { |info|
62
+ # info[:PeakWorkingSetSize] # RAM
63
+ info[:PeakPagefileUsage] # RAM + SWAP
64
+ }
65
+ end
66
+
67
+ def maximum_memory_used
68
+ get_process_memory_info { |info|
69
+ # info[:WorkingSetSize] # RAM
70
+ info[:PagefileUsage] # RAM + SWAP
71
+ }
72
+ end
73
+
74
+ def command_line
75
+ Kernel32.GetCommandLineA().tap do |command_line|
76
+ unless command_line
77
+ warn "Could not get command line via GetCommandLineA()"
78
+ return nil
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,38 @@
1
+ module Perfer
2
+ # A result for a particular job run
3
+ class Result
4
+ extend Forwardable
5
+
6
+ attr_reader :metadata
7
+ attr_accessor :data
8
+ def initialize(metadata, data = [])
9
+ @metadata = metadata.dup
10
+ @data = data
11
+ end
12
+
13
+ def_instance_delegators :@data,
14
+ :<<, :size, :length, :each
15
+
16
+ def_instance_delegators :@metadata, :[], :[]=
17
+
18
+ def stats
19
+ Statistics.new(on(:real))
20
+ end
21
+
22
+ def on(field)
23
+ @data.map { |result| result[field] }
24
+ end
25
+
26
+ def to_json(*args)
27
+ {
28
+ 'json_class' => self.class.name,
29
+ 'data' => @data,
30
+ 'metadata' => @metadata
31
+ }.to_json(*args)
32
+ end
33
+
34
+ def self.json_create json
35
+ new(json['metadata'], json['data'])
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,114 @@
1
+ module Perfer
2
+ class Session
3
+ attr_reader :name, :file, :jobs, :type, :store, :results, :metadata
4
+ attr_writer :current_job
5
+ def initialize(file, name = nil, &block)
6
+ @file = file
7
+ @name = name || file.base.to_s
8
+ @store = Store.for_session(self)
9
+ @results = nil # not an Array, so it errors out if we forgot to load
10
+
11
+ setup_for_run(&block) if block_given?
12
+
13
+ Perfer.sessions << self
14
+ end
15
+
16
+ def setup_for_run
17
+ @jobs = []
18
+ @type = nil # will be decided by API usage (iterate/bench)
19
+ @results_to_save = []
20
+
21
+ @metadata = {
22
+ :file => @file.path,
23
+ :session => @name,
24
+ :ruby => RUBY_DESCRIPTION,
25
+ :command_line => Platform.command_line,
26
+ :run_time => Time.now
27
+ }
28
+ add_config_metadata
29
+ add_git_metadata
30
+ add_bench_file_checksum
31
+ @metadata.freeze
32
+
33
+ yield self
34
+ end
35
+
36
+ def add_config_metadata
37
+ @metadata.merge!(Perfer.configuration.to_hash)
38
+ end
39
+
40
+ def add_git_metadata
41
+ if Git.repository?
42
+ @metadata[:git_branch] = Git.current_branch
43
+ @metadata[:git_commit] = Git.current_commit
44
+ end
45
+ end
46
+
47
+ def add_bench_file_checksum
48
+ checksum = Digest::SHA1.hexdigest(@file.binread)
49
+ if checksum.respond_to?(:encoding) and checksum.encoding != Encoding::ASCII
50
+ checksum.force_encoding(Encoding::ASCII)
51
+ end
52
+ @metadata[:bench_file_checksum] = checksum
53
+ end
54
+
55
+ def load_results
56
+ @results = @store.load
57
+ end
58
+
59
+ def add_result(result)
60
+ @results_to_save << result
61
+ ResultsFormatter.new(result, @jobs).report
62
+ end
63
+
64
+ def run
65
+ puts "Session #{@name} with #{@metadata[:ruby]}"
66
+ print "Taking #{Perfer.configuration.measurements} measurements of"
67
+ puts " at least #{Perfer.configuration.minimal_time}s"
68
+ @jobs.each { |job|
69
+ job.run
70
+ }
71
+ @results_to_save.each { |result|
72
+ @store.append(result)
73
+ }
74
+ @results_to_save.clear
75
+ end
76
+
77
+ # not named #report, to avoid any confusion with Benchmark's #report
78
+ def report_results(options = {})
79
+ load_results
80
+ SessionFormatter.new(self).report(options)
81
+ end
82
+
83
+ def iterate(title, code = nil, data = nil, &block)
84
+ check_benchmark_type(:iterations)
85
+ check_unique_job_title(title)
86
+ @jobs << IterationJob.new(self, title, code, data, &block)
87
+ end
88
+
89
+ def bench(title, &block)
90
+ check_benchmark_type(:input_size)
91
+ check_unique_job_title(title)
92
+ @jobs << InputSizeJob.new(self, title, &block)
93
+ end
94
+
95
+ def measure(&block)
96
+ raise Error, WRONG_MEASURE_USE unless InputSizeJob === @current_job
97
+ @current_job.last_measurement = Perfer.measure(&block)
98
+ end
99
+
100
+ private
101
+ def check_benchmark_type(expected)
102
+ unless !@type or @type == expected
103
+ raise Error, Errors::MIX_BENCH_TYPES
104
+ end
105
+ @type ||= expected
106
+ end
107
+
108
+ def check_unique_job_title(title)
109
+ if @jobs.any? { |job| job.title == title }
110
+ raise Error, Errors::SAME_JOB_TITLES
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,94 @@
1
+ module Perfer
2
+ class Statistics
3
+ include Math
4
+ CONFIDENCE_LEVEL = 0.95
5
+ ALPHA = 1.0 - CONFIDENCE_LEVEL # significance level
6
+
7
+ # Student's t quantiles is used as n is small (= number of measurements)
8
+ # Indexed by: probability, degrees of freedom
9
+ T_QUANTILES = {
10
+ 0.975 => [
11
+ nil,
12
+ 12.71, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, # 1-10
13
+ 2.201, 2.179, 2.160, 2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086, # 11-20
14
+ 2.080, 2.074, 2.069, 2.064, 2.060, 2.056, 2.052, 2.048, 2.045, 2.042, # 21-30
15
+ 2.040, 2.037, 2.035, 2.032, 2.030, 2.028, 2.026, 2.024, 2.023, 2.021 # 31-40
16
+ ]
17
+ }
18
+ { 50 => 2.009, 60 => 2.000, 70 => 1.994,
19
+ 80 => 1.990, 90 => 1.987, 100 => 1.984 }.each_pair { |n, value|
20
+ T_QUANTILES[0.975][n] = value
21
+ }
22
+
23
+ def self.t_quantile(p, degrees_of_freedom)
24
+ if degrees_of_freedom <= 40
25
+ T_QUANTILES[p][degrees_of_freedom]
26
+ elsif degrees_of_freedom <= 100
27
+ T_QUANTILES[p][degrees_of_freedom.round(-1)]
28
+ else
29
+ 1.960
30
+ end
31
+ end
32
+
33
+ def initialize(sample)
34
+ @sample = sample
35
+ end
36
+
37
+ def size
38
+ @sample.size
39
+ end
40
+
41
+ def mean
42
+ @mean ||= @sample.inject(0.0) { |sum, i| sum + i } / size
43
+ end
44
+
45
+ def median
46
+ @median ||= begin
47
+ sorted = @sample.sort
48
+ if size.odd?
49
+ sorted[size/2]
50
+ else
51
+ (sorted[size/2-1] + sorted[size/2]) / 2.0
52
+ end
53
+ end
54
+ end
55
+
56
+ def variance
57
+ mean = mean()
58
+ @sample.inject(0.0) { |var, i|
59
+ d = i - mean
60
+ var + d*d
61
+ } / (size - 1) # unbiased sample variance
62
+ end
63
+
64
+ def standard_deviation
65
+ sqrt(variance)
66
+ end
67
+
68
+ def coefficient_of_variation
69
+ standard_deviation / mean
70
+ end
71
+
72
+ def standard_error
73
+ standard_deviation / sqrt(size)
74
+ end
75
+
76
+ def mean_absolute_deviation
77
+ @sample.inject(0.0) { |dev, i| dev + (i - mean).abs } / size
78
+ end
79
+
80
+ def median_absolute_deviation
81
+ Statistics.new(@sample.map { |i| (i - median).abs }).median
82
+ end
83
+
84
+ # Assumes a standard normal distribution
85
+ # This is half the width of the confidence interval for the mean
86
+ def margin_of_error
87
+ Statistics.t_quantile(1.0 - ALPHA/2, size-1) * standard_error
88
+ end
89
+
90
+ def maximum_absolute_deviation
91
+ @sample.map { |v| (v - mean).abs }.max
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,53 @@
1
+ module Perfer
2
+ class Store
3
+ attr_reader :file
4
+ def initialize(file)
5
+ @file = Path(file)
6
+ end
7
+
8
+ def self.for_session(session)
9
+ path = DIR/'results'
10
+ path.mkpath unless path.exist?
11
+
12
+ bench_file = session.file
13
+
14
+ # get the relative path to root, and relocate in @path
15
+ names = bench_file.each_filename.to_a
16
+ # prepend drive letter on Windows
17
+ names.unshift bench_file.path[0..0].upcase if File.dirname('C:') == 'C:.'
18
+
19
+ new path.join(*names).add_ext('.yml')
20
+ end
21
+
22
+ def delete
23
+ @file.unlink
24
+ end
25
+
26
+ def yaml_load_documents
27
+ docs = @file.open { |f| YAML.load_stream(f) }
28
+ docs = docs.documents unless Array === docs
29
+ docs
30
+ end
31
+
32
+ def load
33
+ return unless @file.exist?
34
+ yaml_load_documents
35
+ end
36
+
37
+ def append(result)
38
+ @file.dir.mkpath unless @file.dir.exist?
39
+ @file.append YAML.dump(result)
40
+ end
41
+
42
+ def save(results)
43
+ @file.dir.mkpath unless @file.dir.exist?
44
+ # ensure results are still ordered by :run_time
45
+ results.sort_by! { |r| r[:run_time] }
46
+ @file.write YAML.dump_stream(*results)
47
+ end
48
+
49
+ def rewrite
50
+ save load
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,9 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'perfer'
3
+ s.summary = 'A benchmark tool for all rubies!'
4
+ s.author = 'eregon'
5
+ s.email = 'eregontp@gmail.com'
6
+ s.homepage = 'https://github.com/jruby/perfer'
7
+ s.files = Dir['lib/**/*.rb'] + %w[README.md LICENSE perfer.gemspec]
8
+ s.version = '0.1.0'
9
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: perfer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - eregon
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-08-15 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description:
15
+ email: eregontp@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lib/perfer/cli.rb
21
+ - lib/perfer/configuration.rb
22
+ - lib/perfer/errors.rb
23
+ - lib/perfer/formatter/measurements_formatter.rb
24
+ - lib/perfer/formatter/results_formatter.rb
25
+ - lib/perfer/formatter/session_formatter.rb
26
+ - lib/perfer/formatter.rb
27
+ - lib/perfer/git.rb
28
+ - lib/perfer/job/input_size_job.rb
29
+ - lib/perfer/job/iteration_job.rb
30
+ - lib/perfer/job.rb
31
+ - lib/perfer/platform/posix.rb
32
+ - lib/perfer/platform/windows.rb
33
+ - lib/perfer/platform.rb
34
+ - lib/perfer/result.rb
35
+ - lib/perfer/session.rb
36
+ - lib/perfer/statistics.rb
37
+ - lib/perfer/store.rb
38
+ - lib/perfer.rb
39
+ - README.md
40
+ - LICENSE
41
+ - perfer.gemspec
42
+ homepage: https://github.com/jruby/perfer
43
+ licenses: []
44
+ post_install_message:
45
+ rdoc_options: []
46
+ require_paths:
47
+ - lib
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ none: false
56
+ requirements:
57
+ - - ! '>='
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubyforge_project:
62
+ rubygems_version: 1.8.23
63
+ signing_key:
64
+ specification_version: 3
65
+ summary: A benchmark tool for all rubies!
66
+ test_files: []
67
+ has_rdoc: