fruity 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.irbrc ADDED
@@ -0,0 +1 @@
1
+ require "./lib/fruity"
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ source "http://rubygems.org"
2
+ # Add dependencies required to use your gem here.
3
+ # Example:
4
+ # gem "activesupport", ">= 2.3.5"
5
+
6
+ # Add dependencies to develop your gem here.
7
+ # Include everything needed to run rake, tests, features, etc.
8
+ group :development do
9
+ gem "rspec", "~> 2.3.0"
10
+ gem "bundler", "~> 1.0.0"
11
+ gem "jeweler", "~> 1.6.4"
12
+ # gem "rcov", ">= 0"
13
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,26 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ diff-lcs (1.1.3)
5
+ git (1.2.5)
6
+ jeweler (1.6.4)
7
+ bundler (~> 1.0)
8
+ git (>= 1.2.5)
9
+ rake
10
+ rake (0.9.2.2)
11
+ rspec (2.3.0)
12
+ rspec-core (~> 2.3.0)
13
+ rspec-expectations (~> 2.3.0)
14
+ rspec-mocks (~> 2.3.0)
15
+ rspec-core (2.3.1)
16
+ rspec-expectations (2.3.0)
17
+ diff-lcs (~> 1.1.2)
18
+ rspec-mocks (2.3.0)
19
+
20
+ PLATFORMS
21
+ ruby
22
+
23
+ DEPENDENCIES
24
+ bundler (~> 1.0.0)
25
+ jeweler (~> 1.6.4)
26
+ rspec (~> 2.3.0)
data/LICENSE.txt ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2012 Marc-Andre Lafortune
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,120 @@
1
+ = Fruity
2
+
3
+ Make sure you're not comparing apples and oranges!
4
+
5
+ Fruity makes it easy to *accurately* and *quickly* compare the performance of different Ruby algorithms.
6
+
7
+ No need to decide how many times to run your stuff or to try to gauge how significant a speed difference can be, Fruity does it for you.
8
+
9
+ = Install
10
+
11
+ gem install fruity
12
+
13
+ = Usage
14
+
15
+ require 'fruity'
16
+ compare do
17
+ slow { sleep(0.06) }
18
+ also_slow { sleep(0.03); sleep(0.03) }
19
+ quick { sleep(0.03) }
20
+ quicker { sleep(0.01) }
21
+ end
22
+
23
+ Prints:
24
+
25
+ quicker is faster than quick by 3.0x ± 0.01
26
+ quick is faster than slow by 1.99x ± 0.01
27
+ slow is similar to also_slow
28
+
29
+ = Alternate APIs
30
+
31
+ There are many ways to specify what to compare.
32
+
33
+ == Methods
34
+
35
+ class Foo
36
+ def slow
37
+ delay(0.2)
38
+ end
39
+ def faster
40
+ delay(0.1)
41
+ end
42
+ end
43
+
44
+ compare :slow, :faster, :on => Foo.new
45
+
46
+ # Also, no need to specify the :on option for global methods:
47
+ def foo
48
+ # ...
49
+ end
50
+ def bar
51
+ # ...
52
+ end
53
+
54
+ compare :foo, :bar
55
+
56
+ == Hash
57
+
58
+ You can pass a hash with executable values; the keys will be used for to name the results.
59
+
60
+ compare(
61
+ foo: ->{ 2 * 2 },
62
+ bar: ->{ 2 ** 2 },
63
+ )
64
+
65
+ == List of callables
66
+
67
+ You can pass a list of callable objects:
68
+
69
+ compare(
70
+ ->{ "foo".upcase },
71
+ Proc.new{ "foo".upcase },
72
+ "foo".method(:upcase),
73
+ )
74
+
75
+ These will be named "Code 1", "Code 2" & "Code 3" respectively.
76
+
77
+ == Block with parameter
78
+
79
+ If you prefer, you can pass a block that accepts a parameter:
80
+
81
+ compare do |cp|
82
+ cp.foo { ... }
83
+ cp.bar { ... }
84
+ end
85
+
86
+ == Approach
87
+
88
+ Benchmarking is not trivial. A well-behaved comparison tool should:
89
+ 1) report there is no significant difference for identical blocks
90
+ 2) but report a very small difference if it is systematic (e.g between ->{ sleep(1.0) } and ->{ sleep(1.001) } )
91
+ 3) report the right performance factor, e.g. a ~2x speed difference for a block that does exactly twice what another is doing, like ->{ 2+2; 2+2 } vs ->{ 2+2 })
92
+
93
+ I know of no other benchmarking tool that passes these tests.
94
+
95
+ For example, the most scientifically minded tool I found (better-benchmark) fails 1 & 3; if reports a statistically significant difference of 0.5% for two identical blocks, while reporting a 4% difference for a block doing twice what the previous block is doing.
96
+
97
+ In addition, all benchmarking tools require the user to either write their own inner loop (e.g. `1000.times{...}` ) or provide a number of inner iterations. Both can be better done automatically by a computer to minimize the time taken to do the test and provide acceptable reliability.
98
+
99
+ == Algorithm
100
+
101
+ We first determine the number of inner iterations needed to get a meaningful clock measurement (see sufficient_magnitude).
102
+ When timing an execution, we always compare to a baseline (the time taken by an empty loop of the same magnitude).
103
+ We call the different executables in succession, to minimize the impact on the order of execution.
104
+ We calculate the error by taking into account the standard deviation of the time samples.
105
+
106
+ == Contributing to fruity
107
+
108
+ * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet
109
+ * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it
110
+ * Fork the project
111
+ * Start a feature/bugfix branch
112
+ * Commit and push until you are happy with your contribution
113
+ * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
114
+ * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it.
115
+
116
+ == Copyright
117
+
118
+ Copyright (c) 2012 Marc-Andre Lafortune. See LICENSE.txt for
119
+ further details.
120
+
data/Rakefile ADDED
@@ -0,0 +1,49 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler'
5
+ begin
6
+ Bundler.setup(:default, :development)
7
+ rescue Bundler::BundlerError => e
8
+ $stderr.puts e.message
9
+ $stderr.puts "Run `bundle install` to install missing gems"
10
+ exit e.status_code
11
+ end
12
+ require 'rake'
13
+
14
+ require 'jeweler'
15
+ Jeweler::Tasks.new do |gem|
16
+ # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
17
+ gem.name = "fruity"
18
+ gem.homepage = "http://github.com/marcandre/fruity"
19
+ gem.license = "MIT"
20
+ gem.summary = %Q{Cool performance comparison tool}
21
+ gem.description = %Q{Comparing apples with apples}
22
+ gem.email = "github@marc-andre.ca"
23
+ gem.authors = ["Marc-Andre Lafortune"]
24
+ # dependencies defined in Gemfile
25
+ end
26
+ Jeweler::RubygemsDotOrgTasks.new
27
+
28
+ require 'rspec/core'
29
+ require 'rspec/core/rake_task'
30
+ RSpec::Core::RakeTask.new(:spec) do |spec|
31
+ spec.pattern = FileList['spec/**/*_spec.rb']
32
+ end
33
+
34
+ RSpec::Core::RakeTask.new(:rcov) do |spec|
35
+ spec.pattern = 'spec/**/*_spec.rb'
36
+ spec.rcov = true
37
+ end
38
+
39
+ task :default => :spec
40
+
41
+ require 'rake/rdoctask'
42
+ Rake::RDocTask.new do |rdoc|
43
+ version = File.exist?('VERSION') ? File.read('VERSION') : ""
44
+
45
+ rdoc.rdoc_dir = 'rdoc'
46
+ rdoc.title = "fruity #{version}"
47
+ rdoc.rdoc_files.include('README*')
48
+ rdoc.rdoc_files.include('lib/**/*.rb')
49
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
data/lib/fruity.rb ADDED
@@ -0,0 +1,3 @@
1
+ require_relative "fruity/base"
2
+
3
+ extend Fruity
@@ -0,0 +1,44 @@
1
+ require_relative "baseline"
2
+ require_relative "util"
3
+ require_relative "runner"
4
+ require_relative "comparison_run"
5
+ require_relative "group"
6
+ require_relative "named_block_collector"
7
+
8
+ Fruity::GLOBAL_SCOPE = self
9
+
10
+ module Fruity
11
+ DEFAULT_OPTIONS = {
12
+ :on => GLOBAL_SCOPE,
13
+ :samples => 20,
14
+ :disable_gc => false,
15
+ :filter => [0, 0.2],
16
+ :baseline => :split,
17
+ }
18
+
19
+ OTHER_OPTIONS = [
20
+ :magnitude,
21
+ :args,
22
+ :self,
23
+ :verbose,
24
+ ]
25
+
26
+ OPTIONS = DEFAULT_OPTIONS.keys + OTHER_OPTIONS
27
+
28
+ def report(*stuff, &block)
29
+ Fruity::Runner.new(Fruity::Group.new(*stuff, &block)).run(&:feedback)
30
+ end
31
+
32
+ def compare(*stuff, &block)
33
+ puts report(*stuff, &block)
34
+ end
35
+
36
+ def study(*stuff, &block)
37
+ run = Fruity::Runner.new(Fruity::Group.new(*stuff, &block)).run(:baseline => :single, &:feedback)
38
+ path = run.export
39
+ `open "#{path}"`
40
+ run
41
+ end
42
+
43
+ extend self
44
+ end
@@ -0,0 +1,79 @@
1
+ module Fruity
2
+
3
+ # Utility module for building
4
+ # baseline equivalents for callables.
5
+ #
6
+ module Baseline
7
+ extend self
8
+
9
+ # Returns the baseline for the given callable object
10
+ # The signature (number of arguments) and type (proc, ...)
11
+ # will be copied as much as possible.
12
+ #
13
+ def [](exec)
14
+ kind = callable_kind(exec)
15
+ signature = callable_signature(exec)
16
+ NOOPs[kind][signature] ||= build_baseline(kind, signature)
17
+ end
18
+
19
+ NOOPs = Hash.new{|h, k| h[k] = {}}
20
+
21
+ def callable_kind(exec)
22
+ if exec.is_a?(Method)
23
+ exec.source_location ? :method : :builtin_method
24
+ elsif exec.lambda?
25
+ :lambda
26
+ else
27
+ :proc
28
+ end
29
+ end
30
+
31
+ def callable_signature(exec)
32
+ if exec.respond_to?(:parameters)
33
+ exec.parameters.map(&:first)
34
+ else
35
+ # Ruby 1.8 didn't have parameters, so rely on arity
36
+ opt = exec.arity < 0
37
+ req = opt ? -1-exec.arity : exec.arity
38
+ signature = [:req] * req
39
+ signature << :rest if opt
40
+ signature
41
+ end
42
+ end
43
+
44
+ PARAM_MAP = {
45
+ :req => "%{name}",
46
+ :opt => "%{name} = nil",
47
+ :rest => "*%{name}",
48
+ :block => "&%{name}",
49
+ }
50
+
51
+ def arg_list(signature)
52
+ signature.map.with_index{|kind, i| PARAM_MAP[kind] % {:name => "p#{i}"}}.join(",")
53
+ end
54
+
55
+ def build_baseline(kind, signature)
56
+ args = "|#{arg_list(signature)}|"
57
+ case kind
58
+ when :lambda, :proc
59
+ eval("#{kind}{#{args}}")
60
+ when :builtin_method
61
+ case signature
62
+ when []
63
+ nil.method(:nil?)
64
+ when [:req]
65
+ nil.method(:==)
66
+ else
67
+ Array.method(:[])
68
+ end
69
+ when :method
70
+ @method_counter ||= 0
71
+ @method_counter += 1
72
+ name = "baseline_#{@method_counter}"
73
+ eval("define_method(:#{name}){#{args}}")
74
+ method(name)
75
+ end
76
+ end
77
+ end
78
+ end
79
+
@@ -0,0 +1,105 @@
1
+ # encoding: utf-8
2
+
3
+ module Fruity
4
+ class ComparisonRun < Struct.new(:group, :timings, :baselines)
5
+ attr_reader :stats
6
+
7
+ # +timings+ must be an array of size `group.size` of arrays of delays
8
+ # or of arrays of [delay, baseline]
9
+ #
10
+ def initialize(group, timings, baselines)
11
+ raise ArgumentError, "Expected timings to be an array with #{group.size} elements (was #{timings.size})" unless timings.size == group.size
12
+ super
13
+
14
+ filter = group.options.fetch(:filter)
15
+
16
+ baseline = Util.filter(baselines, *filter) if baseline_type == :single
17
+
18
+ @stats = timings.map.with_index do |series, i|
19
+ case baseline_type
20
+ when :split
21
+ Util.difference(Util.filter(series, *filter), Util.filter(baselines.fetch(i), *filter))
22
+ when :single
23
+ Util.difference(Util.filter(series, *filter), baseline)
24
+ when :none
25
+ Util.stats(series)
26
+ end
27
+ end.freeze
28
+ end
29
+
30
+ def to_s
31
+ order = (0...group.size).sort_by{|i| @stats[i][:mean] }
32
+ results = group.elements.map{|n, exec| Util.result_of(exec, group.options) }
33
+ order.each_cons(2).map do |i, j|
34
+ cmp = comparison(i, j)
35
+ s = if cmp[:factor] == 1
36
+ "%{cur} is similar to %{vs}%{different}"
37
+ else
38
+ "%{cur} is faster than %{vs} by %{ratio}%{different}"
39
+ end
40
+ s % {
41
+ :cur => group.elements.keys[i],
42
+ :vs => group.elements.keys[j],
43
+ :ratio => format_comparison(cmp),
44
+ :different => results[i] == results[j] ? "" : " (results differ: #{results[i]} vs #{results[j]})"
45
+ }
46
+ end.join("\n")
47
+ end
48
+
49
+ def export(fn = (require "tmpdir"; "#{Dir.tmpdir}/export.csv"))
50
+ require "csv"
51
+ CSV.open(fn, "wb") do |csv|
52
+ head = group.elements.keys
53
+ case baseline_type
54
+ when :split
55
+ head = head.flat_map{|h| [h, "#{head} bl"]}
56
+ data = timings.zip(baselines).flatten(1).transpose
57
+ when :single
58
+ data = (timings + [baselines]).transpose
59
+ head << "baseline"
60
+ else
61
+ data = timings.transpose
62
+ end
63
+ csv << head
64
+ data.each{|vals| csv << vals}
65
+ end
66
+ fn
67
+ end
68
+
69
+ def size
70
+ timings.first.size
71
+ end
72
+
73
+ def factor(cur = 0, vs = 1)
74
+ comparison(cur, vs)[:factor]
75
+ end
76
+
77
+ def factor_range(cur = 0, vs =1)
78
+ comparison(cur, vs)[:min]..comparison(cur, vs)[:max]
79
+ end
80
+
81
+ def comparison(cur = 0, vs = 1)
82
+ Util.compare_stats(@stats[cur], @stats[vs])
83
+ end
84
+
85
+ def format_comparison(cmp)
86
+ ratio = cmp[:factor]
87
+ prec = cmp[:precision]
88
+ if ratio.abs > 1.8
89
+ "#{ratio}x ± #{prec}"
90
+ else
91
+ "#{(ratio - 1)*100}% ± #{prec*100}%"
92
+ end
93
+ end
94
+
95
+ def baseline_type
96
+ if baselines.nil?
97
+ :none
98
+ elsif baselines.first.is_a?(Array)
99
+ :split
100
+ else
101
+ :single
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,102 @@
1
+ module Fruity
2
+
3
+ # A group of callable objects
4
+ #
5
+ class Group
6
+ attr_reader :elements
7
+ attr_reader :options
8
+
9
+ # Pass either a list of callable objects, a Hash of names and callable objects
10
+ # or an Array of methods names with the option :on specifying which object to call
11
+ # them on (or else the methods are assumed to be global, see the README)
12
+ # Another possibility is to use a block; if it accepts an argument,
13
+ #
14
+ def initialize(*args, &block)
15
+ @options = DEFAULT_OPTIONS.dup
16
+ @elements = {}
17
+ @counter = 0
18
+ compare(*args, &block)
19
+ end
20
+
21
+ # Adds things to compare. See +new+ for details on interface
22
+ #
23
+ def compare(*args, &block)
24
+ if args.last.is_a?(Hash) && (args.last.keys - OPTIONS).empty?
25
+ @options.merge!(args.pop)
26
+ end
27
+ case args.first
28
+ when Hash
29
+ raise ArgumentError, "Expected only one hash of {value => executable}, got #{args.size-1} extra arguments" unless args.size == 1
30
+ raise ArgumentError, "Expected values to be executable" unless args.first.values.all?{|v| v.respond_to?(:call)}
31
+ compare_hash(args.first)
32
+ when Symbol, String
33
+ compare_methods(*args)
34
+ else
35
+ compare_lambdas(*args)
36
+ end
37
+ compare_block(block) if block
38
+ end
39
+
40
+ # Returns the maximal sufficient_magnitude for all elements
41
+ # See Util.sufficient_magnitude
42
+ #
43
+ def sufficient_magnitude
44
+ elements.map{|name, exec| Util.sufficient_magnitude(exec, options) }.max
45
+ end
46
+
47
+ # Returns the maximal sufficient_magnitude for all elements
48
+ # and the approximate delay taken for the whole group
49
+ # See Util.sufficient_magnitude
50
+ #
51
+ def sufficient_magnitude_and_delay
52
+ mags_and_delays = elements.map{|name, exec| Util.sufficient_magnitude_and_delay(exec, options) }
53
+ mag = mags_and_delays.map(&:first).max
54
+ delay = mags_and_delays.map{|m, d| d * mag / m}.inject(:+)
55
+ [mag, delay]
56
+ end
57
+
58
+ def size
59
+ elements.size
60
+ end
61
+
62
+ def run(options = {})
63
+ Runner.new(self).run(options)
64
+ end
65
+
66
+ private
67
+ def compare_hash(h)
68
+ elements.merge!(h)
69
+ end
70
+
71
+ def compare_methods(*args)
72
+ on = @options[:on]
73
+ args.each do |m|
74
+ elements[m] = on.method(m)
75
+ end
76
+ end
77
+
78
+ def compare_lambdas(*lambdas)
79
+ lambdas.flat_map{|o| Array(o)}
80
+ lambdas.each do |name, callable|
81
+ name, callable = generate_name(name), name unless callable
82
+ raise "Excepted a callable object, got #{callable}" unless callable.respond_to?(:call)
83
+ elements[name] = callable
84
+ end
85
+ end
86
+
87
+ def compare_block(block)
88
+ collect = NamedBlockCollector.new(@elements)
89
+ if block.arity == 0
90
+ @options[:self] = block.binding.eval("self")
91
+ collect.instance_eval(&block)
92
+ else
93
+ block.call(collect)
94
+ end
95
+ end
96
+
97
+ def generate_name(callable)
98
+ "Code #{@counter += 1}"
99
+ end
100
+ end
101
+ end
102
+
@@ -0,0 +1,12 @@
1
+ module Fruity
2
+ class NamedBlockCollector
3
+ def initialize(to_hash)
4
+ @to_hash = to_hash
5
+ end
6
+
7
+ def method_missing(method, *args, &block)
8
+ super unless args.empty?
9
+ @to_hash[method] = block
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,70 @@
1
+ module Fruity
2
+ class Runner < Struct.new(:group)
3
+ attr_reader :options, :delay, :timings, :baselines
4
+
5
+ def run(options = {})
6
+ prepare(options)
7
+ yield self if block_given?
8
+ sample
9
+ end
10
+
11
+ def feedback
12
+ mess = "Running each test " << (options[:magnitude] == 1 ? "once." : "#{options[:magnitude]} times.")
13
+ if d = delay
14
+ if d > 60
15
+ d = (d / 60).round
16
+ unit = "minute"
17
+ end
18
+ mess << " Test will take about #{d.ceil} #{unit || 'second'}#{d > 1 ? 's' : ''}."
19
+ end
20
+ puts mess
21
+ end
22
+
23
+ private
24
+ def prepare(opt)
25
+ @options = group.options.merge(opt)
26
+ unless options[:magnitude]
27
+ options[:magnitude], @delay = group.sufficient_magnitude_and_delay
28
+ @delay *= options.fetch(:samples)
29
+ end
30
+ end
31
+
32
+ def sample
33
+ send(:"sample_baseline_#{options.fetch(:baseline)}")
34
+ ComparisonRun.new(group, timings, baselines)
35
+ end
36
+
37
+ def sample_baseline_split
38
+ baselines = group.elements.map{|name, exec| Baseline[exec]}
39
+ exec_and_baselines = group.elements.values.zip(baselines)
40
+ @baselines, @timings = options.fetch(:samples).times.map do
41
+ exec_and_baselines.flat_map do |exec, baseline|
42
+ [
43
+ Util.real_time(baseline, options),
44
+ Util.real_time(exec, options),
45
+ ]
46
+ end
47
+ end.transpose.each_slice(2).to_a.transpose
48
+ end
49
+
50
+ def sample_baseline_single
51
+ baseline = Baseline[group.elements.first.last]
52
+ @baselines = []
53
+ @timings = options.fetch(:samples).times.map do
54
+ baselines << Util.real_time(baseline, options)
55
+ group.elements.map do |name, exec|
56
+ Util.real_time(exec, options)
57
+ end
58
+ end.transpose
59
+ end
60
+
61
+ def sample_baseline_none
62
+ @baselines = nil
63
+ @timings = options.fetch(:samples).times.map do
64
+ group.elements.map do |name, exec|
65
+ Util.real_time(exec, options)
66
+ end
67
+ end.transpose
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,192 @@
1
+ require 'benchmark'
2
+ module Fruity
3
+
4
+ # Utility module doing most of the maths
5
+ #
6
+ module Util
7
+ extend self
8
+
9
+ PROPER_TIME_RELATIVE_ERROR = 0.001
10
+
11
+ MEASUREMENTS_BY_REALTIME = 2
12
+ MEASUREMENTS_BY_PROPER_TIME = 2 * MEASUREMENTS_BY_REALTIME
13
+
14
+ # Measures the smallest obtainable delta of two time measurements
15
+ #
16
+ def clock_precision
17
+ @clock_precision ||= 10.times.map do
18
+ t = Time.now
19
+ delta = Time.now - t
20
+ while delta == 0
21
+ delta = Time.now - t
22
+ end
23
+ delta
24
+ end.min
25
+ end
26
+
27
+
28
+ APPROX_POWER = 5
29
+ # Calculates the number +n+ that needs to be passed
30
+ # to +real_time+ to get a result that is precise
31
+ # to within +PROPER_TIME_RELATIVE_ERROR+ when compared
32
+ # to the baseline.
33
+ #
34
+ # For example, ->{ sleep(1) } needs only to be run once to get
35
+ # a measurement that will not be affected by the inherent imprecision
36
+ # of the time measurement (or of the inner loop), but ->{ 2 + 2 } needs to be called
37
+ # a huge number of times so that the returned time measurement is not
38
+ # due in big part to the imprecision of the measurement itself
39
+ # or the inner loop itself.
40
+ #
41
+ def sufficient_magnitude(exec, options = {})
42
+ mag, delay = sufficient_magnitude_and_delay(exec, options)
43
+ mag
44
+ end
45
+
46
+ BASELINE_THRESHOLD = 1.02 # Ratio between two identical baselines is typically < 1.02, while {2+2} compared to baseline is typically > 1.02
47
+
48
+ def sufficient_magnitude_and_delay(exec, options = {})
49
+ power = 0
50
+ min_desired_delta = clock_precision * MEASUREMENTS_BY_PROPER_TIME / PROPER_TIME_RELATIVE_ERROR
51
+ # First, make a gross approximation with a single sample and no baseline
52
+ min_approx_delay = min_desired_delta / (1 << APPROX_POWER)
53
+ while (delay = real_time(exec, options.merge(:magnitude => 1 << power))) < min_approx_delay
54
+ power += [Math.log(min_approx_delay.div(delay + clock_precision), 2), 1].max.floor
55
+ end
56
+
57
+ # Then take a couple of samples, along with a baseline
58
+ power += 1 unless delay > 2 * min_approx_delay
59
+ group = Group.new(exec, Baseline[exec], options.merge(:baseline => :none, :samples => 5, :filter => [0, 0.25], :magnitude => 1 << power))
60
+ stats = group.run.stats
61
+ if stats[0][:mean] / stats[1][:mean] < 2
62
+ # Quite close to baseline, which means we need to be more discriminant
63
+ power += APPROX_POWER
64
+ stats = group.run(:samples => 40, :magnitude => 1 << power).stats
65
+ raise "Given callable can not be reasonably distinguished from an empty block" if stats[0][:mean] / stats[1][:mean] < BASELINE_THRESHOLD
66
+ end
67
+ delta = stats[0][:mean] - stats[1][:mean]
68
+ addl_power = [Math.log(min_desired_delta.div(delta), 2), 0].max.floor
69
+ [
70
+ 1 << (power + addl_power),
71
+ stats[0][:mean] * (1 << addl_power),
72
+ ]
73
+ end
74
+
75
+ # The proper time is the real time taken by calling +exec+
76
+ # number of times given by +options[:magnitude]+ minus
77
+ # the real time for calling an empty executable instead.
78
+ #
79
+ # If +options[:magnitude]+ is not given, it will be calculated to be meaningful.
80
+ #
81
+ def proper_time(exec, options = {})
82
+ unless options.has_key?(:magnitude)
83
+ options = {:magnitude => sufficient_magnitude(exec, options)}.merge(options)
84
+ end
85
+ real_time(exec, options) - real_time(Baseline[exec], options)
86
+ end
87
+
88
+ # Returns the real time taken by calling +exec+
89
+ # number of times given by +options[:magnitude]+
90
+ #
91
+ def real_time(exec, options = {})
92
+ GC.start
93
+ GC.disable if options[:disable_gc]
94
+ n = options.fetch(:magnitude)
95
+ if options.has_key?(:self)
96
+ new_self = options[:self]
97
+ if args = options[:args] and args.size > 0
98
+ Benchmark.realtime{ n.times{ new_self.instance_exec(*args, &exec) } }
99
+ else
100
+ Benchmark.realtime{ n.times{ new_self.instance_eval(&exec) } }
101
+ end
102
+ else
103
+ if args = options[:args] and args.size > 0
104
+ Benchmark.realtime{ n.times{ exec.call(*args) } }
105
+ else
106
+ Benchmark.realtime{ n.times{ exec.call } }
107
+ end
108
+ end
109
+ ensure
110
+ GC.enable
111
+ end
112
+
113
+ # Returns the result of calling +exec+
114
+ #
115
+ def result_of(exec, options = {})
116
+ args = (options[:args] || [])
117
+ if options.has_key?(:self)
118
+ options[:self].instance_exec(*args, &exec)
119
+ else
120
+ exec.call(*args)
121
+ end
122
+ end
123
+
124
+ # Returns the inherent precision of +proper_time+
125
+ #
126
+ def proper_time_precision
127
+ MEASUREMENTS_BY_PROPER_TIME * clock_precision
128
+ end
129
+
130
+ # Calculates stats on some values: {:min, :max, :mean, :sample_std_dev }
131
+ #
132
+ def stats(values)
133
+ sum = values.inject(0, :+)
134
+ # See http://en.wikipedia.org/wiki/Standard_deviation#Rapid_calculation_methods
135
+ q = mean = 0
136
+ values.each_with_index do |x, k|
137
+ prev_mean = mean
138
+ mean += (x - prev_mean) / (k + 1)
139
+ q += (x - mean) * (x - prev_mean)
140
+ end
141
+ sample_std_dev = Math.sqrt( q / (values.size-1) )
142
+ min, max = values.minmax
143
+ {
144
+ :min => min,
145
+ :max => max,
146
+ :mean => mean,
147
+ :sample_std_dev => sample_std_dev
148
+ }
149
+ end
150
+
151
+ # Calculates the stats of the difference of +values+ and +baseline+
152
+ # (which can be stats or timings)
153
+ #
154
+ def difference(values, baseline)
155
+ values, baseline = [values, baseline].map{|x| x.is_a?(Hash) ? x : stats(x)}
156
+ {
157
+ :min => values[:min] - baseline[:max],
158
+ :max => values[:max] - baseline[:min],
159
+ :mean => values[:mean] - baseline[:mean],
160
+ :sample_std_dev => Math.sqrt(values[:sample_std_dev] ** 2 + values[:sample_std_dev] ** 2),
161
+ # See http://stats.stackexchange.com/questions/6096/correct-way-to-calibrate-means
162
+ }
163
+ end
164
+
165
+ # Given two stats +cur+ and +vs+, returns a hash with
166
+ # the ratio between the two, the precision, etc.
167
+ #
168
+ def compare_stats(cur, vs)
169
+ err = (vs[:sample_std_dev] +
170
+ cur[:sample_std_dev] * vs[:mean] / cur[:mean]
171
+ ) / cur[:mean]
172
+
173
+ rounding = err > 0 ? -Math.log(err, 10) : 666
174
+ mean = vs[:mean] / cur[:mean]
175
+ {
176
+ :mean => mean,
177
+ :factor => (mean).round(rounding),
178
+ :max => (vs[:mean] + vs[:sample_std_dev]) / (cur[:mean] - cur[:sample_std_dev]),
179
+ :min => (vs[:mean] - vs[:sample_std_dev]) / (cur[:mean] + cur[:sample_std_dev]),
180
+ :rounding => rounding,
181
+ :precision => 10.0 **(-rounding.round),
182
+ }
183
+ end
184
+
185
+ def filter(series, remove_min_ratio, remove_max_ratio = remove_min_ratio)
186
+ series.sort![
187
+ (remove_min_ratio * series.size).floor ...
188
+ ((1-remove_max_ratio) * series.size).ceil
189
+ ]
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,36 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ module Fruity
4
+ describe Baseline do
5
+ describe "#[]" do
6
+ it "returns an object of the right type" do
7
+ b = Baseline[Proc.new{ 42 }]
8
+ b.class.should == Proc
9
+ b.lambda?.should == false
10
+ b.call.should == nil
11
+
12
+ b = Baseline[lambda{ 42 }]
13
+ b.class.should == Proc
14
+ b.lambda?.should == true
15
+ b.call.should == nil
16
+
17
+ b = Baseline[Fruity.method(:compare)]
18
+ b.class.should == Method
19
+ b.call.should == nil
20
+ end
21
+
22
+ it "copies the arity" do
23
+ b = Baseline[lambda{|a, b| 42}]
24
+ b.call(1, 2).should == nil
25
+ lambda{
26
+ b.call(1)
27
+ }.should raise_error
28
+ lambda{
29
+ b.call(1, 2, 3)
30
+ }.should raise_error
31
+
32
+ Baseline[lambda{|a, *b| 42}].call(1, 2, 3, 4, 5, 6).should == nil
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,20 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ module Fruity
4
+ describe ComparisonRun do
5
+ let(:group) { Group.new ->{1}, ->{2} }
6
+ let(:timings){ ([[1.0, 2.0]] * 10).transpose }
7
+ subject { @run = ComparisonRun.new(group, timings, nil) }
8
+
9
+ its(:comparison) { should == {
10
+ :mean=>2.0,
11
+ :factor=>2.0,
12
+ :max=>2.0,
13
+ :min=>2.0,
14
+ :rounding=>666,
15
+ :precision=>0.0,
16
+ }
17
+ }
18
+
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ require_relative "../lib/fruity"
2
+
3
+ power = 15
4
+ x = 2
5
+ n = 30
6
+ min = 1
7
+ max = 2
8
+ s = 10
9
+ n.times.map do
10
+ group = Fruity::Group.new(*[->{}] * s, *[->{ 2 + 2 }] * s, :baseline => :none, :samples => 20, :magnitude => 1 << power)
11
+ means = group.run.stats.map{|s| s[:mean]}
12
+ noops = means.first(s).sort
13
+ plus = means.last(s).sort
14
+ min = [min, noops.last / noops.first].max
15
+ max = [max, plus.first / noops.last].min
16
+ p min, max
17
+ raise "Damn, #{min} >= #{max}" if min >= max
18
+ end.sort
19
+ puts "Threshold between #{min} and #{max} are ok, suggesting #{Math.sqrt(min * max)}"
@@ -0,0 +1,18 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ describe Fruity do
4
+ it "is able to report that two identical blocks are identical" do
5
+ report = Fruity.report(->{ 2 ** 2 }, ->{ 2 ** 2 })
6
+ report.factor.should == 1.0
7
+ end
8
+
9
+ it "is able to report very small performance differences" do
10
+ report = Fruity.report(->{ sleep(1.0) }, ->{ sleep(1.001) })
11
+ report.factor.should == 1.001
12
+ end
13
+
14
+ it "is able to report a 2x difference even for small blocks" do
15
+ report = Fruity.report(->{ 2 ** 2 }, ->{ 2 ** 2 ; 2 ** 2 })
16
+ report.factor.should == 2.0
17
+ end
18
+ end
@@ -0,0 +1,58 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ TOP_LEVEL = self
4
+ def foo; end
5
+ def bar; end
6
+
7
+ module Fruity
8
+ describe Group do
9
+ lambdas = [->{1},->{2}]
10
+
11
+ it "can be built from lambdas" do
12
+ group = Group.new(*lambdas)
13
+ group.elements.values.should == lambdas
14
+ end
15
+
16
+ it "can be built from a block with no parameter" do
17
+ group = Group.new do
18
+ first(&lambdas.first)
19
+ last(&lambdas.last)
20
+ end
21
+ group.elements.should == {
22
+ :first => lambdas.first,
23
+ :last => lambdas.last,
24
+ }
25
+ group.options[:self].object_id.should equal(self.object_id)
26
+ end
27
+
28
+ it "can be built from a block taking a parameter" do
29
+ group = Group.new do |cmp|
30
+ cmp.first(&lambdas.first)
31
+ cmp.last(&lambdas.last)
32
+ ->{ second{} }.should raise_error(NameError)
33
+ end
34
+ group.elements.should == {
35
+ :first => lambdas.first,
36
+ :last => lambdas.last,
37
+ }
38
+ group.options[:self].should == nil
39
+ end
40
+
41
+ it "can be built from list of method names and an object" do
42
+ str = "Hello"
43
+ group = Group.new(:upcase, :downcase, :on => str)
44
+ group.elements.should == {
45
+ :upcase => str.method(:upcase),
46
+ :downcase => str.method(:downcase),
47
+ }
48
+ end
49
+
50
+ it "can be built from list of method names and an object" do
51
+ group = Group.new(:foo, :bar)
52
+ group.elements.should == {
53
+ :foo => TOP_LEVEL.method(:foo),
54
+ :bar => TOP_LEVEL.method(:bar),
55
+ }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "../lib/fruity"
2
+
3
+ compare do
4
+ one { 42.nil? }
5
+ two { 42.nil?; 42.nil? }
6
+ end
7
+
8
+ compare do
9
+ two { 42.nil?; 42.nil? }
10
+ two_again { 42.nil?; 42.nil? }
11
+ three { 42.nil?; 42.nil?; 42.nil? }
12
+ four { 42.nil?; 42.nil?; 42.nil?; 42.nil? }
13
+ five { 42.nil?; 42.nil?; 42.nil?; 42.nil?; 42.nil? }
14
+ end
@@ -0,0 +1,26 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ module Fruity
4
+ describe Runner do
5
+ let(:group) { Group.new(:upcase, :downcase, :on => "Hello") }
6
+ let(:runner){ Runner.new(group) }
7
+
8
+ it "runs from a Group" do
9
+ run = runner.run(:samples => 42, :magnitude => 100)
10
+ run.timings.should be_array_of_size(2, 42)
11
+ run.baselines.should be_array_of_size(2, 42)
12
+ end
13
+
14
+ it "can use a single baseline" do
15
+ run = runner.run(:samples => 42, :magnitude => 100, :baseline => :single)
16
+ run.timings.should be_array_of_size(2, 42)
17
+ run.baselines.should be_array_of_size(42)
18
+ end
19
+
20
+ it "can use no baseline" do
21
+ run = runner.run(:samples => 42, :magnitude => 100, :baseline => :none)
22
+ run.timings.should be_array_of_size(2, 42)
23
+ run.baselines.should == nil
24
+ end
25
+ end
26
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,47 @@
1
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
3
+ require 'rspec'
4
+ require 'fruity'
5
+
6
+ # Requires supporting files with custom matchers and macros, etc,
7
+ # in ./support/ and its subdirectories.
8
+ Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f}
9
+
10
+ RSpec.configure do |config|
11
+
12
+ end
13
+
14
+ RSpec::Matchers.define :be_between do |low,high|
15
+ match do |actual|
16
+ @low, @high = low, high
17
+ actual.between? low, high
18
+ end
19
+
20
+ failure_message_for_should do |actual|
21
+ "expected to be between #{@low} and #{@high}, but was #{actual}"
22
+ end
23
+
24
+ failure_message_for_should_not do |actual|
25
+ "expected not to be between #{@low} and #{@high}, but was #{actual}"
26
+ end
27
+ end
28
+
29
+ RSpec::Matchers.define :be_array_of_size do |*dimensions|
30
+ match do |actual|
31
+ @dimensions = dimensions
32
+ @actual = []
33
+ while actual.is_a?(Array)
34
+ @actual << actual.size
35
+ actual = actual.first
36
+ end
37
+ @dimensions == @actual
38
+ end
39
+
40
+ failure_message_for_should do |actual|
41
+ "expected to be an array of dimension #{@dimensions.join('x')} but was #{@actual.join('x')}"
42
+ end
43
+
44
+ failure_message_for_should_not do |actual|
45
+ "expected not to be an array of dimension #{@dimensions.join('x')}"
46
+ end
47
+ end
data/spec/util_spec.rb ADDED
@@ -0,0 +1,69 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ module Fruity
4
+ describe Util do
5
+ its(:clock_precision) { should == 1.0e-6 }
6
+
7
+ its(:proper_time_precision) { should == 4.0e-06 }
8
+
9
+ describe :sufficient_magnitude do
10
+ it "returns a big value for a quick (but not trivial)" do
11
+ Util.sufficient_magnitude(->{ 2.nil? }).should > 10000
12
+ end
13
+
14
+ it "return 1 for a sufficiently slow block" do
15
+ Util.sufficient_magnitude(->{sleep(0.01)}).should == 1
16
+ end
17
+
18
+ it "should raise an error for a trivial block" do
19
+ ->{
20
+ Util.sufficient_magnitude(Proc.new{})
21
+ }.should raise_error
22
+ end
23
+ end
24
+
25
+ describe :stats do
26
+ it "returns cools stats on the given values" do
27
+ Util.stats([0, 4]).should == {
28
+ :min => 0,
29
+ :max => 4,
30
+ :mean => 2,
31
+ :sample_std_dev => 2.8284271247461903,
32
+ }
33
+ end
34
+ end
35
+
36
+ describe :difference do
37
+ it "returns stats for the difference of two series" do
38
+ s = Util.stats([0, 4])
39
+ Util.difference(s, s).should == {
40
+ :min => -4,
41
+ :max => 4,
42
+ :mean => 0,
43
+ :sample_std_dev => 4,
44
+ }
45
+ end
46
+
47
+ it "gives similar results when comparing an exec and its baseline from stats on proper_time" do
48
+ exec = ->{ 2 ** 3 ** 4 }
49
+ options = {:magnitude => Util.sufficient_magnitude(exec) }
50
+ n = 100
51
+ timings = [exec, ->{}].map do |e|
52
+ n.times.map { Util.real_time(e, options) }
53
+ end
54
+ proper = Util.stats(timings.transpose.map{|e, b| e - b})
55
+ diff = Util.difference(*timings)
56
+ diff[:mean].should be_within(Float::EPSILON).of(proper[:mean])
57
+ diff[:max].should be_between(proper[:max], proper[:max]*2)
58
+ diff[:min].should <= proper[:min]
59
+ diff[:sample_std_dev].should be_between(proper[:sample_std_dev], 2 * proper[:sample_std_dev])
60
+ end
61
+ end
62
+
63
+ describe :filter do
64
+ it "returns the filtered series" do
65
+ Util.filter([4, 5, 2, 3, 1], 0.21, 0.39).should == [2, 3, 4]
66
+ end
67
+ end
68
+ end
69
+ end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fruity
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Marc-Andre Lafortune
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-02-08 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rspec
16
+ requirement: &2170028460 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 2.3.0
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *2170028460
25
+ - !ruby/object:Gem::Dependency
26
+ name: bundler
27
+ requirement: &2170027780 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: 1.0.0
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *2170027780
36
+ - !ruby/object:Gem::Dependency
37
+ name: jeweler
38
+ requirement: &2170047220 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: 1.6.4
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: *2170047220
47
+ description: Comparing apples with apples
48
+ email: github@marc-andre.ca
49
+ executables: []
50
+ extensions: []
51
+ extra_rdoc_files:
52
+ - LICENSE.txt
53
+ - README.rdoc
54
+ files:
55
+ - .irbrc
56
+ - Gemfile
57
+ - Gemfile.lock
58
+ - LICENSE.txt
59
+ - README.rdoc
60
+ - Rakefile
61
+ - VERSION
62
+ - lib/fruity.rb
63
+ - lib/fruity/base.rb
64
+ - lib/fruity/baseline.rb
65
+ - lib/fruity/comparison_run.rb
66
+ - lib/fruity/group.rb
67
+ - lib/fruity/named_block_collector.rb
68
+ - lib/fruity/runner.rb
69
+ - lib/fruity/util.rb
70
+ - spec/baseline_spec.rb
71
+ - spec/comparison_run_spec.rb
72
+ - spec/find_thresold.rb
73
+ - spec/fruity_spec.rb
74
+ - spec/group_spec.rb
75
+ - spec/manual_test.rb
76
+ - spec/runner_spec.rb
77
+ - spec/spec.opts
78
+ - spec/spec_helper.rb
79
+ - spec/util_spec.rb
80
+ homepage: http://github.com/marcandre/fruity
81
+ licenses:
82
+ - MIT
83
+ post_install_message:
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ none: false
89
+ requirements:
90
+ - - ! '>='
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ segments:
94
+ - 0
95
+ hash: -3031735817276906554
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubyforge_project:
104
+ rubygems_version: 1.8.10
105
+ signing_key:
106
+ specification_version: 3
107
+ summary: Cool performance comparison tool
108
+ test_files: []