flori-bullshit 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rbconfig'
4
+ require 'fileutils'
5
+ include FileUtils::Verbose
6
+
7
+ include Config
8
+
9
+ file = 'lib/bullshit.rb'
10
+ libdir = CONFIG["sitelibdir"]
11
+ install(file, libdir, :mode => 0755)
12
+ mkdir_p subdir = File.join(libdir, 'bullshit')
13
+ for f in Dir['lib/bullshit/*.rb']
14
+ install(f, subdir)
15
+ end
@@ -0,0 +1,2244 @@
1
+ # = Bullshit - Benchmarking in Ruby
2
+ #
3
+ # == Description
4
+ #
5
+ # == Usage
6
+ #
7
+ # == Author
8
+ #
9
+ # Florian Frank mailto:flori@ping.de
10
+ #
11
+ # == License
12
+ #
13
+ # This is free software; you can redistribute it and/or modify it under the
14
+ # terms of the GNU General Public License Version 2 as published by the Free
15
+ # Software Foundation: www.gnu.org/copyleft/gpl.html
16
+ #
17
+ # == Download
18
+ #
19
+ # The latest version of this library can be downloaded at
20
+ #
21
+ # * http://rubyforge.org/frs/?group_id=8323
22
+ #
23
+ # The homepage of this library is located at
24
+ #
25
+ # * http://bullshit.rubyforge.org
26
+ #
27
+ # == Example
28
+ #
29
+
30
+ require 'dslkit'
31
+ require 'enumerator'
32
+
33
+ begin
34
+ require 'bullshit/version'
35
+ rescue LoadError
36
+ end
37
+
38
+ # Module that includes all constants of the bullshit library.
39
+ module Bullshit
40
+ COLUMNS = 79 # Number of columns in the output.
41
+
42
+ NAME_COLUMN_SIZE = 5 # Number of columns used for row names.
43
+
44
+ Infinity = 1.0 / 0 # Refers to floating point infinity.
45
+
46
+ RUBY_DESCRIPTION = "ruby %s (%s patchlevel %s) [%s]" %
47
+ [ RUBY_VERSION, RUBY_RELEASE_DATE, RUBY_PATCHLEVEL, RUBY_PLATFORM ]
48
+
49
+ # This class implements a continued fraction of the form:
50
+ #
51
+ # b_1
52
+ # a_0 + -------------------------
53
+ # b_2
54
+ # a_1 + --------------------
55
+ # b_3
56
+ # a_2 + ---------------
57
+ # b_4
58
+ # a_3 + ----------
59
+ # b_5
60
+ # a_4 + -----
61
+ # ...
62
+ #
63
+ class ContinuedFraction
64
+ # Creates a continued fraction instance. With the defaults for_a { 1 } and
65
+ # for_b { 1 } it approximates the golden ration phi if evaluated.
66
+ def initialize
67
+ @a = proc { 1.0 }
68
+ @b = proc { 1.0 }
69
+ end
70
+
71
+ # Creates a ContinuedFraction instances and passes its arguments to a call
72
+ # to for_a.
73
+ def self.for_a(arg = nil, &block)
74
+ new.for_a(arg, &block)
75
+ end
76
+
77
+ # Creates a ContinuedFraction instances and passes its arguments to a call
78
+ # to for_b.
79
+ def self.for_b(arg = nil, &block)
80
+ new.for_b(arg, &block)
81
+ end
82
+
83
+ # This method either takes a block or an argument +arg+. The argument +arg+
84
+ # has to respond to an integer index n >= 0 and return the value a_n. The
85
+ # block has to return the value for a_n when +n+ is passed as the first
86
+ # argument to the block. If a_n is dependent on an +x+ value (see the call
87
+ # method) the +x+ will be the second argument of the block.
88
+ def for_a(arg = nil, &block)
89
+ if arg and !block
90
+ @a = arg
91
+ elsif block and !arg
92
+ @a = block
93
+ else
94
+ raise ArgumentError, "exactly one argument or one block required"
95
+ end
96
+ self
97
+ end
98
+
99
+ # This method either takes a block or an argument +arg+. The argument +arg+
100
+ # has to respond to an integer index n >= 1 and return the value b_n. The
101
+ # block has to return the value for b_n when +n+ is passed as the first
102
+ # argument to the block. If b_n is dependent on an +x+ value (see the call
103
+ # method) the +x+ will be the second argument of the block.
104
+ def for_b(arg = nil, &block)
105
+ if arg and !block
106
+ @b = arg
107
+ elsif block and !arg
108
+ @b = block
109
+ else
110
+ raise ArgumentError, "exactly one argument or one block required"
111
+ end
112
+ self
113
+ end
114
+
115
+ # Returns the value for a_n or a_n(x).
116
+ def a(n, x = nil)
117
+ result = if x
118
+ @a[n, x]
119
+ else
120
+ @a[n]
121
+ end and result.to_f
122
+ end
123
+
124
+ # Returns the value for b_n or b_n(x).
125
+ def b(n, x = nil)
126
+ result = if x
127
+ @b[n, x]
128
+ else
129
+ @b[n]
130
+ end and result.to_f
131
+ end
132
+
133
+ # Evaluates the continued fraction for the value +x+ (if any) with the
134
+ # accuracy +epsilon+ and +max_iterations+ as the maximum number of
135
+ # iterations using the Wallis-method with scaling.
136
+ def call(x = nil, epsilon = 1E-16, max_iterations = 1 << 31)
137
+ c_0, c_1 = 1.0, a(0, x)
138
+ c_1 == nil and return 0 / 0.0
139
+ d_0, d_1 = 0.0, 1.0
140
+ result = c_1 / d_1
141
+ n = 0
142
+ error = 1 / 0.0
143
+ $DEBUG and warn "n=%u, a=%f, b=nil, c=%f, d=%f result=%f, error=nil" %
144
+ [ n, c_1, c_1, d_1, result ]
145
+ while n < max_iterations and error > epsilon
146
+ n += 1
147
+ a_n, b_n = a(n, x), b(n, x)
148
+ a_n and b_n or break
149
+ c_2 = a_n * c_1 + b_n * c_0
150
+ d_2 = a_n * d_1 + b_n * d_0
151
+ if c_2.infinite? or d_2.infinite?
152
+ if a_n != 0
153
+ c_2 = c_1 + (b_n / a_n * c_0)
154
+ d_2 = d_1 + (b_n / a_n * d_0)
155
+ elsif b_n != 0
156
+ c_2 = (a_n / b_n * c_1) + c_0
157
+ d_2 = (a_n / b_n * d_1) + d_0
158
+ else
159
+ raise Errno::ERANGE
160
+ end
161
+ end
162
+ r = c_2 / d_2
163
+ error = (r / result - 1).abs
164
+
165
+ result = r
166
+
167
+ $DEBUG and warn "n=%u, a=%f, b=%f, c=%f, d=%f, result=%f, error=%.16f" %
168
+ [ n, a_n, b_n, c_1, d_1, result, error ]
169
+
170
+ c_0, c_1 = c_1, c_2
171
+ d_0, d_1 = d_1, d_2
172
+ end
173
+ n >= max_iterations and raise Errno::ERANGE
174
+ result
175
+ end
176
+
177
+ alias [] call
178
+
179
+ # Returns this continued fraction as a Proc object which takes the same
180
+ # arguments like its call method does.
181
+ def to_proc
182
+ proc { |*a| call(*a) }
183
+ end
184
+ end
185
+
186
+ module ModuleFunctions
187
+ module_function
188
+
189
+ # Return the angle +degree+ in radians.
190
+ def angle(degree)
191
+ Math.tan(Math::PI * degree / 180)
192
+ end
193
+
194
+ # Return the percentage number as a value in the range 0..1.
195
+ def percent(number)
196
+ number / 100.0
197
+ end
198
+
199
+ # Let a window of size +window_size+ slide over the array +array+ and yield
200
+ # to the window array.
201
+ def array_window(array, window_size)
202
+ window_size < 1 and raise ArgumentError, "window_size = #{window_size} < 1"
203
+ window_size = window_size.to_i
204
+ window_size += 1 if window_size % 2 == 0
205
+ radius = window_size / 2
206
+ array.each_index do |i|
207
+ ws = window_size
208
+ from = i - radius
209
+ negative_from = false
210
+ if from < 0
211
+ negative_from = true
212
+ ws += from
213
+ from = 0
214
+ end
215
+ a = array[from, ws]
216
+ if (diff = window_size - a.size) > 0
217
+ mean = a.inject(0.0) { |s, x| s + x } / a.size
218
+ a = if negative_from
219
+ [ mean ] * diff + a
220
+ else
221
+ a + [ mean ] * diff
222
+ end
223
+ end
224
+ yield a
225
+ end
226
+ nil
227
+ end
228
+ end
229
+
230
+ # An excpeption raised by the bullshit library.
231
+ class BullshitException < StandardError; end
232
+
233
+ # A Clock instance is used to take measurements while benchmarking.
234
+ class Clock
235
+ TIMES = [ :real, :total, :user, :system ]
236
+
237
+ ALL_COLUMNS = [ :scatter ] + TIMES + [ :repeat ]
238
+
239
+ TIMES_MAX = TIMES.map { |t| t.to_s.size }.max
240
+
241
+ # Returns a Clock instance for CaseMethod instance +bc_method+.
242
+ def initialize(bc_method)
243
+ @bc_method = bc_method
244
+ @times = Hash.new { |h, k| h[k] = [] }
245
+ @repeat = 0
246
+ @scatter = 0
247
+ end
248
+
249
+ # Use a Clock instance to measure the time necessary to do
250
+ # bc_method.case.iterations repetitions of +bc_method+.
251
+ def self.repeat(bc_method)
252
+ clock = new(bc_method)
253
+ bs = clock.case.batch_size.abs
254
+ bs = 1 if !bs or bs < 0
255
+ clock.case.iterations.times do
256
+ bc_method.before_run
257
+ $DEBUG and warn "Calling #{bc_method.name}."
258
+ clock.inc_scatter
259
+ clock.measure do
260
+ bs.times { yield }
261
+ end
262
+ bc_method.after_run
263
+ end
264
+ clock
265
+ end
266
+
267
+ # Use a Clock instance to measure how many repetitions of +bc_method+ can
268
+ # be done in bc_method.case.duration seconds (a float value). If the
269
+ # bc_method.case.batch_size is >1 duration is multiplied by batch_size
270
+ # because the measured times are averaged by batch_size.
271
+ def self.time(bc_method)
272
+ clock = new(bc_method)
273
+ duration = clock.case.duration.abs
274
+ if bs = clock.case.batch_size and bs > 1
275
+ duration *= bs
276
+ end
277
+ until_at = Time.now + duration
278
+ bs = clock.case.batch_size.abs
279
+ bs = 1 if !bs or bs < 0
280
+ begin
281
+ bc_method.before_run
282
+ $DEBUG and warn "Calling #{bc_method.name}."
283
+ clock.inc_scatter
284
+ clock.measure do
285
+ bs.times { yield }
286
+ end
287
+ bc_method.after_run
288
+ end until clock.time > until_at
289
+ clock
290
+ end
291
+
292
+ # Iterate over the +range+ of the RangeCase instance of this +bc_method+
293
+ # and take measurements (including scattering).
294
+ def self.scale_range(bc_method)
295
+ clock = new(bc_method)
296
+ my_case = clock.case
297
+ bs = my_case.batch_size.abs
298
+ bs = 1 if !bs or bs < 0
299
+ for a in my_case.range
300
+ begin
301
+ my_case.args = (a.dup rescue a).freeze
302
+ clock.inc_scatter
303
+ my_case.scatter.times do
304
+ bc_method.before_run
305
+ $DEBUG and warn "Calling #{bc_method.name}."
306
+ clock.measure do
307
+ bs.times { yield }
308
+ end
309
+ bc_method.after_run
310
+ end
311
+ ensure
312
+ my_case.args = nil
313
+ end
314
+ end
315
+ clock
316
+ end
317
+
318
+ # The benchmark case class this clock belongs to (via bc_method).
319
+ def case
320
+ @bc_method.case.class
321
+ end
322
+
323
+ # Returns the benchmark method for this Clock instance.
324
+ attr_reader :bc_method
325
+
326
+ # Number of repetitions this clock has measured.
327
+ attr_accessor :repeat
328
+
329
+ # Last time object used for real time measurement.
330
+ attr_reader :time
331
+
332
+ # Return all the slopes of linear regressions computed during data
333
+ # truncation phase.
334
+ attr_reader :slopes
335
+
336
+ # Add the array +times+ to this clock's time measurements. +times+ consists
337
+ # of the time measurements in float values in order of TIMES.
338
+ def <<(times)
339
+ r = times.shift
340
+ @repeat += 1 if @times[:repeat].last != r
341
+ @times[:repeat] << r
342
+ TIMES.zip(times) { |t, time| @times[t] << time.to_f }
343
+ self
344
+ end
345
+
346
+ # Returns a Hash of Analysis object for all of TIMES's time keys.
347
+ def analysis
348
+ @analysis ||= Hash.new do |h, time|
349
+ time = time.to_sym
350
+ times = @times[time]
351
+ h[time] = Analysis.new(times)
352
+ end
353
+ end
354
+
355
+ # Return true, if other's mean value is indistinguishable from this
356
+ # object's mean after filtering out the noise from the measurements with a
357
+ # Welch's t-Test. This mean's that differences in the mean of both clocks
358
+ # might not inidicate a real performance difference and may be caused by
359
+ # chance.
360
+ def cover?(other)
361
+ time = self.case.compare_time.to_sym
362
+ analysis[time].cover?(other.analysis[time], self.case.covering.alpha_level.abs)
363
+ end
364
+
365
+ # Return column names in relation to Clock#to_a method.
366
+ def self.to_a
367
+ %w[ #scatter ] + TIMES + %w[ repeat ]
368
+ end
369
+
370
+ # Returns the measurements as an array of arrays.
371
+ def to_a
372
+ if @repeat >= 1
373
+ (::Bullshit::Clock::ALL_COLUMNS).map do |t|
374
+ analysis[t].measurements
375
+ end.transpose
376
+ else
377
+ []
378
+ end
379
+ end
380
+
381
+ # Takes the times an returns an array, consisting of the times in the order
382
+ # of enumerated in the TIMES constant.
383
+ def take_time
384
+ @time, times = Time.now, Process.times
385
+ user_time = times.utime + times.cutime # user time of this process and its children
386
+ system_time = times.stime + times.cstime # system time of this process and its children
387
+ total_time = user_time + system_time # total time of this process and its children
388
+ [ @time.to_f, total_time, user_time, system_time ]
389
+ end
390
+
391
+ # Increment scatter counter by one.
392
+ def inc_scatter
393
+ @scatter += 1
394
+ end
395
+
396
+ # Take a single measurement. This method should be called with the code to
397
+ # benchmark in a block.
398
+ def measure
399
+ before = take_time
400
+ yield
401
+ after = take_time
402
+ @repeat += 1
403
+ @times[:repeat] << @repeat
404
+ @times[:scatter] << @scatter
405
+ bs = self.case.batch_size.abs
406
+ if bs and bs > 1
407
+ TIMES.each_with_index { |t, i| @times[t] << (after[i] - before[i]) / bs }
408
+ else
409
+ TIMES.each_with_index { |t, i| @times[t] << after[i] - before[i] }
410
+ end
411
+ @analysis = nil
412
+ end
413
+
414
+ # Returns the sample standard deviation for the +time+ (one of TIMES'
415
+ # symbols).
416
+ def sample_standard_deviation(time)
417
+ analysis[time.to_sym].sample_standard_deviation
418
+ end
419
+
420
+ # Returns the sample standard deviation for the +time+ (one of TIMES'
421
+ # symbols) in percentage of its arithmetic mean.
422
+ def sample_standard_deviation_percentage(time)
423
+ analysis[time.to_sym].sample_standard_deviation_percentage
424
+ end
425
+
426
+ # Returns the minimum for the +time+ (one of TIMES' symbols).
427
+ def min(time)
428
+ analysis[time.to_sym].min
429
+ end
430
+
431
+ # Returns the maximum for the +time+ (one of TIMES' symbols).
432
+ def max(time)
433
+ analysis[time.to_sym].max
434
+ end
435
+
436
+ # Returns the median of the +time+ values (one of TIMES' symbols).
437
+ def median(time)
438
+ analysis[time.to_sym].median
439
+ end
440
+
441
+ # Returns the +p+-percentile of the +time+ values (one of TIMES' symbols).
442
+ def percentile(time, p = 50)
443
+ analysis[time.to_sym].percentile p
444
+ end
445
+
446
+ # Returns the q value for the Ljung-Box statistic of this +time+'s
447
+ # analysis.detect_autocorrelation method.
448
+ def detect_autocorrelation(time)
449
+ analysis[time.to_sym].detect_autocorrelation(
450
+ self.case.autocorrelation.max_lags.to_i,
451
+ self.case.autocorrelation.alpha_level.abs)
452
+ end
453
+
454
+ # Return a result hash with the number of :very_low, :low, :high, and
455
+ # :very_high outliers, determined by the box plotting algorithm run with
456
+ # :median and :iqr parameters. If no outliers were found or the iqr is less
457
+ # than epsilon, nil is returned.
458
+ def detect_outliers(time)
459
+ analysis[time.to_sym].detect_outliers(self.case.outliers_factor.abs)
460
+ end
461
+
462
+ TIMES.each do |time|
463
+ define_method(time) { analysis[time].sum }
464
+ end
465
+
466
+ # Seconds per call (mean)
467
+ def call_time_mean
468
+ mean(self.case.compare_time)
469
+ end
470
+
471
+ # Calls per second of the +call_time_type+, e. g. :call_time_mean or
472
+ # :call_time_median.
473
+ def calls(call_time_type)
474
+ __send__(call_time_type) ** -1
475
+ end
476
+
477
+ # Calls per second (mean)
478
+ def calls_mean
479
+ call_time_mean ** -1
480
+ end
481
+
482
+ # Seconds per call (median)
483
+ def call_time_median
484
+ median(self.case.compare_time)
485
+ end
486
+
487
+ # Calls per second (median)
488
+ def calls_median
489
+ call_time_median ** -1
490
+ end
491
+
492
+ # Returns the arithmetic mean of +time+.
493
+ def arithmetic_mean(time)
494
+ analysis[time.to_sym].mean
495
+ end
496
+
497
+ alias mean arithmetic_mean
498
+
499
+ # Returns the harmonic mean of +time+.
500
+ def harmonic_mean(time)
501
+ analysis[time.to_sym].harmonic_mean
502
+ end
503
+
504
+ # Returns the geometric mean of +time+.
505
+ def geometric_mean(time)
506
+ analysis[time.to_sym].geometric_mean
507
+ end
508
+
509
+ # The times which should be displayed in the output.
510
+ def self.times
511
+ TIMES.map { |t| t.to_s }
512
+ end
513
+
514
+ # Return the Histogram for the +time+ values.
515
+ def histogram(time)
516
+ analysis[time.to_sym].histogram(self.case.histogram.bins)
517
+ end
518
+
519
+ # Return the array of autocorrelation values for +time+.
520
+ def autocorrelation(time)
521
+ analysis[time.to_sym].autocorrelation
522
+ end
523
+
524
+ # Returns the arrays for the autocorrelation plot, the first array for the
525
+ # numbers of lag measured, the second for the autocorrelation value.
526
+ def autocorrelation_plot(time)
527
+ r = autocorrelation time
528
+ start = @times[:repeat].first
529
+ ende = (start + r.size)
530
+ (start...ende).to_a.zip(r)
531
+ end
532
+
533
+ # Return the result of CaseMethod#file_path for this clock's bc_method.
534
+ def file_path(*args)
535
+ @bc_method.file_path(*args)
536
+ end
537
+
538
+ # Truncate the measurements stored in this clock starting from the integer
539
+ # +offset+.
540
+ def truncate_data(offset)
541
+ for t in ALL_COLUMNS
542
+ times = @times[t]
543
+ @times[t] = @times[t][offset, times.size]
544
+ @repeat = @times[t].size
545
+ end
546
+ @analysis = nil
547
+ self
548
+ end
549
+
550
+ # Find an offset from the start of the measurements in this clock to
551
+ # truncate the initial data until a stable state has been reached and
552
+ # return it as an integer.
553
+ def find_truncation_offset
554
+ truncation = self.case.truncate_data
555
+ slope_angle = self.case.truncate_data.slope_angle.abs
556
+ time = self.case.compare_time.to_sym
557
+ ms = analysis[time].measurements.reverse
558
+ offset = ms.size - 1
559
+ @slopes = []
560
+ ModuleFunctions.array_window(ms, truncation.window_size) do |data|
561
+ lr = LinearRegression.new(data)
562
+ a = lr.a
563
+ @slopes << [ offset, a ]
564
+ a.abs > slope_angle and break
565
+ offset -= 1
566
+ end
567
+ offset < 0 ? 0 : offset
568
+ end
569
+ end
570
+
571
+ # A histogram gives an overview of measurement time values.
572
+ class Histogram
573
+ # Create a Histogram for +clock+ using the measurements for +time+.
574
+ def initialize(analysis, bins)
575
+ @analysis = analysis
576
+ @bins = bins
577
+ @result = compute
578
+ end
579
+
580
+ # Number of bins for this Histogram.
581
+ attr_reader :bins
582
+
583
+ # Return the computed histogram as an array of arrays.
584
+ def to_a
585
+ @result
586
+ end
587
+
588
+ # Display this histogram to +output+, +width+ is the parameter for
589
+ # +prepare_display+
590
+ def display(output = $stdout, width = 50)
591
+ d = prepare_display(width)
592
+ for l, bar, r in d
593
+ output << "%11.5f -|%s\n" % [ (l + r) / 2.0, "*" * bar ]
594
+ end
595
+ self
596
+ end
597
+
598
+ private
599
+
600
+ # Returns an array of tuples (l, c, r) where +l+ is the left bin edge, +c+
601
+ # the +width+-normalized frequence count value, and +r+ the right bin
602
+ # edge. +width+ is usually an integer number representing the width of a
603
+ # histogram bar.
604
+ def prepare_display(width)
605
+ r = @result.reverse
606
+ factor = width.to_f / (r.transpose[1].max)
607
+ r.map { |l, c, r| [ l, (c * factor).round, r ] }
608
+ end
609
+
610
+ # Computes the histogram and returns it as an array of tuples (l, c, r).
611
+ def compute
612
+ @analysis.measurements.empty? and return []
613
+ last_r = -Infinity
614
+ min = @analysis.min
615
+ max = @analysis.max
616
+ step = (max - min) / bins.to_f
617
+ Array.new(bins) do |i|
618
+ l = min + i * step
619
+ r = min + (i + 1) * step
620
+ c = 0
621
+ @analysis.measurements.each do |x|
622
+ x > last_r and (x <= r || i == bins - 1) and c += 1
623
+ end
624
+ last_r = r
625
+ [ l, c, r ]
626
+ end
627
+ end
628
+ end
629
+
630
+ # This class is used to find the root of a function with Newton's bisection
631
+ # method.
632
+ class NewtonBisection
633
+ # Creates a NewtonBisection instance for +function+, a one-argument block.
634
+ def initialize(&function)
635
+ @function = function
636
+ end
637
+
638
+ # The function, passed into the constructor.
639
+ attr_reader :function
640
+
641
+ # Return a bracket around a root, starting from the initial +range+. The
642
+ # method returns nil, if no such bracket around a root could be found after
643
+ # +n+ tries with the scaling +factor+.
644
+ def bracket(range = -1..1, n = 50, factor = 1.6)
645
+ x1, x2 = range.first.to_f, range.last.to_f
646
+ x1 >= x2 and raise ArgumentError, "bad initial range #{range}"
647
+ f1, f2 = @function[x1], @function[x2]
648
+ n.times do
649
+ f1 * f2 < 0 and return x1..x2
650
+ if f1.abs < f2.abs
651
+ f1 = @function[x1 += factor * (x1 - x2)]
652
+ else
653
+ f2 = @function[x2 += factor * (x2 - x1)]
654
+ end
655
+ end
656
+ return
657
+ end
658
+
659
+ # Find the root of function in +range+ and return it. The method raises a
660
+ # BullshitException, if no such root could be found after +n+ tries and in
661
+ # the +epsilon+ environment.
662
+ def solve(range = nil, n = 1 << 16, epsilon = 1E-16)
663
+ if range
664
+ x1, x2 = range.first.to_f, range.last.to_f
665
+ x1 >= x2 and raise ArgumentError, "bad initial range #{range}"
666
+ elsif range = bracket
667
+ x1, x2 = range.first, range.last
668
+ else
669
+ raise ArgumentError, "bracket could not be determined"
670
+ end
671
+ f = @function[x1]
672
+ fmid = @function[x2]
673
+ f * fmid >= 0 and raise ArgumentError, "root must be bracketed in #{range}"
674
+ root = if f < 0
675
+ dx = x2 - x1
676
+ x1
677
+ else
678
+ dx = x1 - x2
679
+ x2
680
+ end
681
+ n.times do
682
+ fmid = @function[xmid = root + (dx *= 0.5)]
683
+ fmid < 0 and root = xmid
684
+ dx.abs < epsilon or fmid == 0 and return root
685
+ end
686
+ raise BullshitException, "too many iterations (#{n})"
687
+ end
688
+ end
689
+
690
+ module Functions
691
+ module_function
692
+
693
+ include Math
694
+ extend Math
695
+
696
+ LANCZOS_COEFFICIENTS = [
697
+ 0.99999999999999709182,
698
+ 57.156235665862923517,
699
+ -59.597960355475491248,
700
+ 14.136097974741747174,
701
+ -0.49191381609762019978,
702
+ 0.33994649984811888699e-4,
703
+ 0.46523628927048575665e-4,
704
+ -0.98374475304879564677e-4,
705
+ 0.15808870322491248884e-3,
706
+ -0.21026444172410488319e-3,
707
+ 0.21743961811521264320e-3,
708
+ -0.16431810653676389022e-3,
709
+ 0.84418223983852743293e-4,
710
+ -0.26190838401581408670e-4,
711
+ 0.36899182659531622704e-5,
712
+ ]
713
+
714
+ HALF_LOG_2_PI = 0.5 * log(2 * Math::PI)
715
+
716
+ # Returns the natural logarithm of Euler gamma function value for +x+ using
717
+ # the Lanczos approximation.
718
+ if method_defined?(:lgamma)
719
+ def log_gamma(x)
720
+ lgamma(x).first
721
+ end
722
+ else
723
+ def log_gamma(x)
724
+ if x.nan? || x <= 0
725
+ 0 / 0.0
726
+ else
727
+ sum = 0.0
728
+ (LANCZOS_COEFFICIENTS.size - 1).downto(1) do |i|
729
+ sum += LANCZOS_COEFFICIENTS[i] / (x + i)
730
+ end
731
+ sum += LANCZOS_COEFFICIENTS[0]
732
+ tmp = x + 607.0 / 128 + 0.5
733
+ (x + 0.5) * log(tmp) - tmp + HALF_LOG_2_PI + log(sum / x)
734
+ end
735
+ rescue Errno::ERANGE, Errno::EDOM
736
+ 0 / 0.0
737
+ end
738
+ end
739
+
740
+ # Returns the natural logarithm of the beta function value for +(a, b)+.
741
+ def log_beta(a, b)
742
+ log_gamma(a) + log_gamma(b) - log_gamma(a + b)
743
+ rescue Errno::ERANGE, Errno::EDOM
744
+ 0 / 0.0
745
+ end
746
+
747
+ # Return an approximation value of Euler's regularized beta function for
748
+ # +x+, +a+, and +b+ with an error <= +epsilon+, but only iterate
749
+ # +max_iterations+-times.
750
+ def beta_regularized(x, a, b, epsilon = 1E-16, max_iterations = 1 << 16)
751
+ x, a, b = x.to_f, a.to_f, b.to_f
752
+ case
753
+ when a.nan? || b.nan? || x.nan? || a <= 0 || b <= 0 || x < 0 || x > 1
754
+ 0 / 0.0
755
+ when x > (a + 1) / (a + b + 2)
756
+ 1 - beta_regularized(1 - x, b, a, epsilon, max_iterations)
757
+ else
758
+ fraction = ContinuedFraction.for_b do |n, x|
759
+ if n % 2 == 0
760
+ m = n / 2.0
761
+ (m * (b - m) * x) / ((a + (2 * m) - 1) * (a + (2 * m)))
762
+ else
763
+ m = (n - 1) / 2.0
764
+ -((a + m) * (a + b + m) * x) / ((a + 2 * m) * (a + 2 * m + 1))
765
+ end
766
+ end
767
+ exp(a * log(x) + b * log(1.0 - x) - log(a) - log_beta(a, b)) /
768
+ fraction[x, epsilon, max_iterations]
769
+ end
770
+ rescue Errno::ERANGE, Errno::EDOM
771
+ 0 / 0.0
772
+ end
773
+
774
+ # Return an approximation of the regularized gammaP function for +x+ and
775
+ # +a+ with an error of <= +epsilon+, but only iterate
776
+ # +max_iterations+-times.
777
+ def gammaP_regularized(x, a, epsilon = 1E-16, max_iterations = 1 << 16)
778
+ x, a = x.to_f, a.to_f
779
+ case
780
+ when a.nan? || x.nan? || a <= 0 || x < 0
781
+ 0 / 0.0
782
+ when x == 0
783
+ 0.0
784
+ when 1 <= a && a < x
785
+ 1 - gammaQ_regularized(x, a, epsilon, max_iterations)
786
+ else
787
+ n = 0
788
+ an = 1 / a
789
+ sum = an
790
+ while an.abs > epsilon && n < max_iterations
791
+ n += 1
792
+ an *= x / (a + n)
793
+ sum += an
794
+ end
795
+ if n >= max_iterations
796
+ raise Errno::ERANGE
797
+ else
798
+ exp(-x + a * log(x) - log_gamma(a)) * sum
799
+ end
800
+ end
801
+ rescue Errno::ERANGE, Errno::EDOM
802
+ 0 / 0.0
803
+ end
804
+
805
+ # Return an approximation of the regularized gammaQ function for +x+ and
806
+ # +a+ with an error of <= +epsilon+, but only iterate
807
+ # +max_iterations+-times.
808
+ def gammaQ_regularized(x, a, epsilon = 1E-16, max_iterations = 1 << 16)
809
+ x, a = x.to_f, a.to_f
810
+ case
811
+ when a.nan? || x.nan? || a <= 0 || x < 0
812
+ 0 / 0.0
813
+ when x == 0
814
+ 1.0
815
+ when a > x || a < 1
816
+ 1 - gammaP_regularized(x, a, epsilon, max_iterations)
817
+ else
818
+ fraction = ContinuedFraction.for_a do |n, x|
819
+ (2 * n + 1) - a + x
820
+ end.for_b do |n, x|
821
+ n * (a - n)
822
+ end
823
+ exp(-x + a * log(x) - log_gamma(a)) *
824
+ fraction[x, epsilon, max_iterations] ** -1
825
+ end
826
+ rescue Errno::ERANGE, Errno::EDOM
827
+ 0 / 0.0
828
+ end
829
+
830
+ ROOT2 = sqrt(2)
831
+
832
+ A = -8 * (Math::PI - 3) / (3 * Math::PI * (Math::PI - 4))
833
+
834
+ # Returns an approximate value for the error function's value for +x+.
835
+ def erf(x)
836
+ r = sqrt(1 - exp(-x ** 2 * (4 / Math::PI + A * x ** 2) / (1 + A * x ** 2)))
837
+ x < 0 ? -r : r
838
+ end unless method_defined?(:erf)
839
+ end
840
+
841
+ # This class is used to compute the T-Distribution.
842
+ class TDistribution
843
+ include Functions
844
+
845
+ # Returns a TDistribution instance for the degrees of freedom +df+.
846
+ def initialize(df)
847
+ @df = df
848
+ end
849
+
850
+ # Degrees of freedom.
851
+ attr_reader :df
852
+
853
+ # Returns the cumulative probability (p-value) of the TDistribution for the
854
+ # t-value +x+.
855
+ def probability(x)
856
+ if x == 0
857
+ 0.5
858
+ else
859
+ t = beta_regularized(@df / (@df + x ** 2.0), 0.5 * @df, 0.5)
860
+ if x < 0.0
861
+ 0.5 * t
862
+ else
863
+ 1 - 0.5 * t
864
+ end
865
+ end
866
+ end
867
+
868
+ # Returns the inverse cumulative probability (t-value) of the TDistribution
869
+ # for the probability +p+.
870
+ def inverse_probability(p)
871
+ case
872
+ when p <= 0
873
+ -1 / 0.0
874
+ when p >= 1
875
+ 1 / 0.0
876
+ else
877
+ begin
878
+ bisect = NewtonBisection.new { |x| probability(x) - p }
879
+ range = bisect.bracket(-10..10)
880
+ bisect.solve(range, 1_000_000)
881
+ rescue
882
+ 0 / 0.0
883
+ end
884
+ end
885
+ end
886
+ end
887
+
888
+ # This class is used to compute the Normal Distribution.
889
+ class NormalDistribution
890
+ include Functions
891
+
892
+ # Creates a NormalDistribution instance for the values +mu+ and +sigma+.
893
+ def initialize(mu = 0.0, sigma = 1.0)
894
+ @mu, @sigma = mu.to_f, sigma.to_f
895
+ end
896
+
897
+ attr_reader :mu
898
+
899
+ attr_reader :sigma
900
+
901
+ # Returns the cumulative probability (p-value) of the NormalDistribution
902
+ # for the value +x+.
903
+ def probability(x)
904
+ 0.5 * (1 + erf((x - @mu) / (@sigma * ROOT2)))
905
+ end
906
+
907
+ # Returns the inverse cumulative probability value of the
908
+ # NormalDistribution for the probability +p+.
909
+ def inverse_probability(p)
910
+ case
911
+ when p <= 0
912
+ -1 / 0.0
913
+ when p >= 1
914
+ 1 / 0.0
915
+ when p == 0.5 # This is a bit sloppy, maybe improve this later.
916
+ @mu
917
+ else
918
+ begin
919
+ NewtonBisection.new { |x| probability(x) - p }.solve(nil, 1_000_000)
920
+ rescue
921
+ 0 / 0.0
922
+ end
923
+ end
924
+ end
925
+ end
926
+
927
+ STD_NORMAL_DISTRIBUTION = NormalDistribution.new
928
+
929
+ # This class is used to compute the Chi-Square Distribution.
930
+ class ChiSquareDistribution
931
+ include Functions
932
+
933
+ # Creates a ChiSquareDistribution for +df+ degrees of freedom.
934
+ def initialize(df)
935
+ @df = df
936
+ @df_half = @df / 2.0
937
+ end
938
+
939
+ attr_reader :df
940
+
941
+ # Returns the cumulative probability (p-value) of the ChiSquareDistribution
942
+ # for the value +x+.
943
+ def probability(x)
944
+ if x < 0
945
+ 0.0
946
+ else
947
+ gammaP_regularized(x / 2, @df_half)
948
+ end
949
+ end
950
+
951
+ # Returns the inverse cumulative probability value of the
952
+ # NormalDistribution for the probability +p+.
953
+ def inverse_probability(p)
954
+ case
955
+ when p <= 0, p >= 1
956
+ 0.0
957
+ else
958
+ begin
959
+ bisect = NewtonBisection.new { |x| probability(x) - p }
960
+ range = bisect.bracket 0.5..10
961
+ bisect.solve(range, 1_000_000)
962
+ rescue
963
+ 0 / 0.0
964
+ end
965
+ end
966
+ end
967
+ end
968
+
969
+ # This class computes a linear regression for the given image and domain data
970
+ # sets.
971
+ class LinearRegression
972
+ def initialize(image, domain = (0...image.size).to_a)
973
+ image.size != domain.size and raise ArgumentError,
974
+ "image and domain have unequal sizes"
975
+ @image, @domain = image, domain
976
+ compute
977
+ end
978
+
979
+ # The image data as an array.
980
+ attr_reader :image
981
+
982
+ # The domain data as an array.
983
+ attr_reader :domain
984
+
985
+ # The slope of the line.
986
+ attr_reader :a
987
+
988
+ # The offset of the line.
989
+ attr_reader :b
990
+
991
+ # Return true if the slope of the underlying data (not the sample data
992
+ # passed into the constructor of this LinearRegression instance) is likely
993
+ # (with alpha level _alpha_) to be zero.
994
+ def slope_zero?(alpha = 0.05)
995
+ df = @image.size - 2
996
+ return true if df <= 0 # not enough values to check
997
+ t = tvalue(alpha)
998
+ td = TDistribution.new df
999
+ t.abs <= td.inverse_probability(1 - alpha.abs / 2.0).abs
1000
+ end
1001
+
1002
+ # Returns the residues of this linear regression in relation to the given
1003
+ # domain and image.
1004
+ def residues
1005
+ result = []
1006
+ @domain.zip(@image) do |x, y|
1007
+ result << y - (@a * x + @b)
1008
+ end
1009
+ result
1010
+ end
1011
+
1012
+ private
1013
+
1014
+ def compute
1015
+ size = @image.size
1016
+ sum_xx = sum_xy = sum_x = sum_y = 0.0
1017
+ @domain.zip(@image) do |x, y|
1018
+ x += 1
1019
+ sum_xx += x ** 2
1020
+ sum_xy += x * y
1021
+ sum_x += x
1022
+ sum_y += y
1023
+ end
1024
+ @a = (size * sum_xy - sum_x * sum_y) / (size * sum_xx - sum_x ** 2)
1025
+ @b = (sum_y - @a * sum_x) / size
1026
+ self
1027
+ end
1028
+
1029
+ def tvalue(alpha = 0.05)
1030
+ df = @image.size - 2
1031
+ return 0.0 if df <= 0
1032
+ sse_y = 0.0
1033
+ @domain.zip(@image) do |x, y|
1034
+ f_x = a * x + b
1035
+ sse_y += (y - f_x) ** 2
1036
+ end
1037
+ mean = @image.inject(0.0) { |s, y| s + y } / @image.size
1038
+ sse_x = @domain.inject(0.0) { |s, x| s + (x - mean) ** 2 }
1039
+ t = a / (Math.sqrt(sse_y / df) / Math.sqrt(sse_x))
1040
+ t.nan? ? 0.0 : t
1041
+ end
1042
+ end
1043
+
1044
+ # This class is used to analyse the time measurements and compute their
1045
+ # statistics.
1046
+ class Analysis
1047
+ def initialize(measurements)
1048
+ @measurements = measurements
1049
+ @measurements.freeze
1050
+ end
1051
+
1052
+ # Returns the array of measurements.
1053
+ attr_reader :measurements
1054
+
1055
+ # Returns the number of measurements, on which the analysis is based.
1056
+ def size
1057
+ @measurements.size
1058
+ end
1059
+
1060
+ # Returns the variance of the measurements.
1061
+ def variance
1062
+ @variance ||= sum_of_squares / size
1063
+ end
1064
+
1065
+ # Returns the sample_variance of the measurements.
1066
+ def sample_variance
1067
+ @sample_variance ||= size > 1 ? sum_of_squares / (size - 1.0) : 0.0
1068
+ end
1069
+
1070
+ # Returns the sum of squares (the sum of the squared deviations) of the
1071
+ # measurements.
1072
+ def sum_of_squares
1073
+ @sum_of_squares ||= @measurements.inject(0.0) { |s, t| s + (t - arithmetic_mean) ** 2 }
1074
+ end
1075
+
1076
+ # Returns the standard deviation of the measurements.
1077
+ def standard_deviation
1078
+ @sample_deviation ||= Math.sqrt(variance)
1079
+ end
1080
+
1081
+ # Returns the standard deviation of the measurements in percentage of the
1082
+ # arithmetic mean.
1083
+ def standard_deviation_percentage
1084
+ @standard_deviation_percentage ||= 100.0 * standard_deviation / arithmetic_mean
1085
+ end
1086
+
1087
+ # Returns the sample standard deviation of the measurements.
1088
+ def sample_standard_deviation
1089
+ @sample_standard_deviation ||= Math.sqrt(sample_variance)
1090
+ end
1091
+
1092
+ # Returns the sample standard deviation of the measurements in percentage
1093
+ # of the arithmetic mean.
1094
+ def sample_standard_deviation_percentage
1095
+ @sample_standard_deviation_percentage ||= 100.0 * sample_standard_deviation / arithmetic_mean
1096
+ end
1097
+
1098
+ # Returns the sum of all measurements.
1099
+ def sum
1100
+ @sum ||= @measurements.inject(0.0) { |s, t| s + t }
1101
+ end
1102
+
1103
+ # Returns the arithmetic mean of the measurements.
1104
+ def arithmetic_mean
1105
+ @arithmetic_mean ||= sum / size
1106
+ end
1107
+
1108
+ alias mean arithmetic_mean
1109
+
1110
+ # Returns the harmonic mean of the measurements. If any of the measurements
1111
+ # is less than or equal to 0.0, this method returns NaN.
1112
+ def harmonic_mean
1113
+ @harmonic_mean ||= (
1114
+ sum = @measurements.inject(0.0) { |s, t|
1115
+ if t > 0
1116
+ s + 1.0 / t
1117
+ else
1118
+ break nil
1119
+ end
1120
+ }
1121
+ sum ? size / sum : 0 / 0.0
1122
+ )
1123
+ end
1124
+
1125
+ # Returns the geometric mean of the measurements. If any of the
1126
+ # measurements is less than 0.0, this method returns NaN.
1127
+ def geometric_mean
1128
+ @geometric_mean ||= (
1129
+ sum = @measurements.inject(0.0) { |s, t|
1130
+ case
1131
+ when t > 0
1132
+ s + Math.log(t)
1133
+ when t == 0
1134
+ break :null
1135
+ else
1136
+ break nil
1137
+ end
1138
+ }
1139
+ case sum
1140
+ when :null
1141
+ 0.0
1142
+ when Float
1143
+ Math.exp(sum / size)
1144
+ else
1145
+ 0 / 0.0
1146
+ end
1147
+ )
1148
+ end
1149
+
1150
+ # Returns the minimum of the measurements.
1151
+ def min
1152
+ @min ||= @measurements.min
1153
+ end
1154
+
1155
+ # Returns the maximum of the measurements.
1156
+ def max
1157
+ @max ||= @measurements.max
1158
+ end
1159
+
1160
+ # Returns the +p+-percentile of the measurements.
1161
+ # There are many methods to compute the percentile, this method uses the
1162
+ # the weighted average at x_(n + 1)p, which allows p to be in 0...100
1163
+ # (excluding the 100).
1164
+ def percentile(p = 50)
1165
+ (0...100).include?(p) or
1166
+ raise ArgumentError, "p = #{p}, but has to be in (0...100)"
1167
+ p /= 100.0
1168
+ @sorted ||= @measurements.sort
1169
+ r = p * (@sorted.size + 1)
1170
+ r_i = r.to_i
1171
+ r_f = r - r_i
1172
+ if r_i >= 1
1173
+ result = @sorted[r_i - 1]
1174
+ if r_i < @sorted.size
1175
+ result += r_f * (@sorted[r_i] - @sorted[r_i - 1])
1176
+ end
1177
+ else
1178
+ result = @sorted[0]
1179
+ end
1180
+ result
1181
+ end
1182
+
1183
+ alias median percentile
1184
+
1185
+ # Use an approximation of the Welch-Satterthwaite equation to compute the
1186
+ # degrees of freedom for Welch's t-test.
1187
+ def compute_welch_df(other)
1188
+ (sample_variance / size + other.sample_variance / other.size) ** 2 / (
1189
+ (sample_variance ** 2 / (size ** 2 * (size - 1))) +
1190
+ (other.sample_variance ** 2 / (other.size ** 2 * (other.size - 1))))
1191
+ end
1192
+
1193
+ # Returns the t value of the Welch's t-test between this Analysis
1194
+ # instance and the +other+.
1195
+ def t_welch(other)
1196
+ signal = arithmetic_mean - other.arithmetic_mean
1197
+ noise = Math.sqrt(sample_variance / size +
1198
+ other.sample_variance / other.size)
1199
+ signal / noise
1200
+ rescue Errno::EDOM
1201
+ 0.0
1202
+ end
1203
+
1204
+ # Returns an estimation of the common standard deviation of the
1205
+ # measurements of this and +other+.
1206
+ def common_standard_deviation(other)
1207
+ Math.sqrt(common_variance(other))
1208
+ end
1209
+
1210
+ # Returns an estimation of the common variance of the measurements of this
1211
+ # and +other+.
1212
+ def common_variance(other)
1213
+ (size - 1) * sample_variance + (other.size - 1) * other.sample_variance /
1214
+ (size + other.size - 2)
1215
+ end
1216
+
1217
+ # Compute the # degrees of freedom for Student's t-test.
1218
+ def compute_student_df(other)
1219
+ size + other.size - 2
1220
+ end
1221
+
1222
+ # Returns the t value of the Student's t-test between this Analysis
1223
+ # instance and the +other+.
1224
+ def t_student(other)
1225
+ signal = arithmetic_mean - other.arithmetic_mean
1226
+ noise = common_standard_deviation(other) *
1227
+ Math.sqrt(size ** -1 + size ** -1)
1228
+ rescue Errno::EDOM
1229
+ 0.0
1230
+ end
1231
+
1232
+ # Compute a sample size, that will more likely yield a mean difference
1233
+ # between this instance's measurements and those of +other+. Use +alpha+
1234
+ # and +beta+ as levels for the first- and second-order errors.
1235
+ def suggested_sample_size(other, alpha = 0.05, beta = 0.05)
1236
+ alpha, beta = alpha.abs, beta.abs
1237
+ signal = arithmetic_mean - other.arithmetic_mean
1238
+ df = size + other.size - 2
1239
+ pooled_variance_estimate = (sum_of_squares + other.sum_of_squares) / df
1240
+ td = TDistribution.new df
1241
+ (((td.inverse_probability(alpha) + td.inverse_probability(beta)) *
1242
+ Math.sqrt(pooled_variance_estimate)) / signal) ** 2
1243
+ end
1244
+
1245
+ # Return true, if the Analysis instance covers the +other+, that is their
1246
+ # arithmetic mean value is most likely to be equal for the +alpha+ error
1247
+ # level.
1248
+ def cover?(other, alpha = 0.05)
1249
+ t = t_welch(other)
1250
+ td = TDistribution.new(compute_welch_df(other))
1251
+ t.abs < td.inverse_probability(1 - alpha.abs / 2.0)
1252
+ end
1253
+
1254
+ # Return the confidence interval for the arithmetic mean with alpha level +alpha+ of
1255
+ # the measurements of this Analysis instance as a Range object.
1256
+ def confidence_interval(alpha = 0.05)
1257
+ td = TDistribution.new(size - 1)
1258
+ t = td.inverse_probability(alpha / 2).abs
1259
+ delta = t * sample_standard_deviation / Math.sqrt(size)
1260
+ (arithmetic_mean - delta)..(arithmetic_mean + delta)
1261
+ end
1262
+
1263
+ # Returns the array of autovariances (of length size - 1).
1264
+ def autovariance
1265
+ Array.new(size - 1) do |k|
1266
+ s = 0.0
1267
+ 0.upto(size - k - 1) do |i|
1268
+ s += (@measurements[i] - arithmetic_mean) * (@measurements[i + k] - arithmetic_mean)
1269
+ end
1270
+ s / size
1271
+ end
1272
+ end
1273
+
1274
+ # Returns the array of autocorrelation values c_k / c_0 (of length size -
1275
+ # 1).
1276
+ def autocorrelation
1277
+ c = autovariance
1278
+ Array.new(c.size) { |k| c[k] / c[0] }
1279
+ end
1280
+
1281
+ # Returns the d-value for the Durbin-Watson statistic. The value is d << 2
1282
+ # for positive, d >> 2 for negative and d around 2 for no autocorrelation.
1283
+ def durbin_watson_statistic
1284
+ e = linear_regression.residues
1285
+ e.size <= 1 and return 2.0
1286
+ (1...e.size).inject(0.0) { |s, i| s + (e[i] - e[i - 1]) ** 2 } /
1287
+ e.inject(0.0) { |s, x| s + x ** 2 }
1288
+ end
1289
+
1290
+ # Returns the q value of the Ljung-Box statistic for the number of lags
1291
+ # +lags+. A higher value might indicate autocorrelation in the measurements of
1292
+ # this Analysis instance. This method returns nil if there weren't enough
1293
+ # (at least lags) lags available.
1294
+ def ljung_box_statistic(lags = 20)
1295
+ r = autocorrelation
1296
+ lags >= r.size and return
1297
+ n = size
1298
+ n * (n + 2) * (1..lags).inject(0.0) { |s, i| s + r[i] ** 2 / (n - i) }
1299
+ end
1300
+
1301
+ # This method tries to detect autocorrelation with the Ljung-Box
1302
+ # statistic. If enough lags can be considered it returns a hash with
1303
+ # results, otherwise nil is returned. The keys are
1304
+ # :lags: the number of lags,
1305
+ # :alpha_level: the alpha level for the test,
1306
+ # :q: the value of the ljung_box_statistic,
1307
+ # :p: the p-value computed, if p is higher than alpha no correlation was detected,
1308
+ # :detected: true if a correlation was found.
1309
+ def detect_autocorrelation(lags = 20, alpha_level = 0.05)
1310
+ if q = ljung_box_statistic(lags)
1311
+ p = ChiSquareDistribution.new(lags).probability(q)
1312
+ return {
1313
+ :lags => lags,
1314
+ :alpha_level => alpha_level,
1315
+ :q => q,
1316
+ :p => p,
1317
+ :detected => p >= 1 - alpha_level,
1318
+ }
1319
+ end
1320
+ end
1321
+
1322
+ # Return a result hash with the number of :very_low, :low, :high, and
1323
+ # :very_high outliers, determined by the box plotting algorithm run with
1324
+ # :median and :iqr parameters. If no outliers were found or the iqr is
1325
+ # less than epsilon, nil is returned.
1326
+ def detect_outliers(factor = 3.0, epsilon = 1E-5)
1327
+ half_factor = factor / 2.0
1328
+ quartile1 = percentile(25)
1329
+ quartile3 = percentile(75)
1330
+ iqr = quartile3 - quartile1
1331
+ iqr < epsilon and return
1332
+ result = @measurements.inject(Hash.new(0)) do |h, t|
1333
+ extreme =
1334
+ case t
1335
+ when -Infinity..(quartile1 - factor * iqr)
1336
+ :very_low
1337
+ when (quartile1 - factor * iqr)..(quartile1 - half_factor * iqr)
1338
+ :low
1339
+ when (quartile1 + half_factor * iqr)..(quartile3 + factor * iqr)
1340
+ :high
1341
+ when (quartile3 + factor * iqr)..Infinity
1342
+ :very_high
1343
+ end and h[extreme] += 1
1344
+ h
1345
+ end
1346
+ unless result.empty?
1347
+ result[:median] = median
1348
+ result[:iqr] = iqr
1349
+ result[:factor] = factor
1350
+ result
1351
+ end
1352
+ end
1353
+
1354
+ # Returns the LinearRegression object for the equation a * x + b which
1355
+ # represents the line computed by the linear regression algorithm.
1356
+ def linear_regression
1357
+ @linear_regression ||= LinearRegression.new @measurements
1358
+ end
1359
+
1360
+ # Returns a Histogram instance with +bins+ as the number of bins for this
1361
+ # analysis' measurements.
1362
+ def histogram(bins)
1363
+ Histogram.new(self, bins)
1364
+ end
1365
+ end
1366
+
1367
+ CaseMethod = Struct.new(:name, :case, :clock)
1368
+
1369
+ # This class' instance represents a method to be benchmarked.
1370
+ class CaseMethod
1371
+ # Return the short name of this CaseMethod instance, that is without the
1372
+ # "benchmark_" prefix, e. g. "foo".
1373
+ def short_name
1374
+ @short_name ||= name.sub(/\Abenchmark_/, '')
1375
+ end
1376
+
1377
+ # The comment for this method.
1378
+ attr_accessor :comment
1379
+
1380
+ # Returns the long_name of this CaseMethod of the form Foo#bar.
1381
+ def long_name
1382
+ result = "#{self.case}##{short_name}"
1383
+ result = "#{result} (#{comment})" if comment
1384
+ result
1385
+ end
1386
+
1387
+ # Return the setup_name, e. g. "setup_foo".
1388
+ def setup_name
1389
+ 'setup_' + short_name
1390
+ end
1391
+
1392
+ # Return the before_name, e. g. "before_foo".
1393
+ def before_name
1394
+ 'before_' + short_name
1395
+ end
1396
+
1397
+ # Return the after_name, e. g. "after_foo".
1398
+ def after_name
1399
+ 'after_' + short_name
1400
+ end
1401
+
1402
+ # Return the teardown_name, e. g. "teardown_foo".
1403
+ def teardown_name
1404
+ 'teardown_' + short_name
1405
+ end
1406
+
1407
+ # Return true if this CaseMethod#clock covers other.clock.
1408
+ def cover?(other)
1409
+ clock.cover?(other.clock)
1410
+ end
1411
+
1412
+ # Call before method of this CaseMethod before benchmarking it.
1413
+ def before_run
1414
+ if self.case.respond_to? before_name
1415
+ $DEBUG and warn "Calling #{before_name}."
1416
+ self.case.__send__(before_name)
1417
+ end
1418
+ end
1419
+
1420
+ # Call after method of this CaseMethod after benchmarking it.
1421
+ def after_run
1422
+ if self.case.respond_to? after_name
1423
+ $DEBUG and warn "Calling #{after_name}."
1424
+ self.case.__send__(after_name)
1425
+ end
1426
+ end
1427
+
1428
+ # Return the file name for +type+ with +suffix+ (if any) for this clock.
1429
+ def file_path(type = nil, suffix = '.dat')
1430
+ name = self.case.class.benchmark_name.dup
1431
+ name << '#' << short_name
1432
+ type and name << '-' << type
1433
+ name << suffix
1434
+ File.expand_path(name, self.case.class.output_dir)
1435
+ end
1436
+
1437
+ # Load the data of file +fp+ into this clock.
1438
+ def load(fp = file_path)
1439
+ self.clock = self.case.class.clock.new self
1440
+ $DEBUG and warn "Loading '#{fp}' into clock."
1441
+ File.open(fp, 'r') do |f|
1442
+ f.each do |line|
1443
+ line.chomp!
1444
+ line =~ /^\s*#/ and next
1445
+ clock << line.split(/\t/)
1446
+ end
1447
+ end
1448
+ self
1449
+ end
1450
+ end
1451
+
1452
+ module CommonConstants
1453
+ extend DSLKit::Constant
1454
+
1455
+ constant :yes, true
1456
+
1457
+ constant :no, false
1458
+ end
1459
+
1460
+ # This is the base class of all Benchmarking Cases.
1461
+ class Case
1462
+
1463
+ # All subclasses of Case are extended with this module.
1464
+ module CaseExtension
1465
+ def inherited(klass)
1466
+ Case.cases << klass
1467
+ end
1468
+
1469
+ extend DSLKit::DSLAccessor
1470
+ extend DSLKit::Constant
1471
+
1472
+ include CommonConstants
1473
+
1474
+ dsl_accessor :benchmark_name do name end
1475
+
1476
+ dsl_accessor :clock, Clock
1477
+
1478
+ constant :real
1479
+
1480
+ constant :total
1481
+
1482
+ constant :user
1483
+
1484
+ constant :system
1485
+
1486
+ dsl_accessor :compare_time, :real
1487
+
1488
+ dsl_accessor :warmup, false
1489
+
1490
+ dsl_accessor :batch_size, 1
1491
+
1492
+ class TruncateData
1493
+ extend DSLKit::DSLAccessor
1494
+ extend DSLKit::Constant
1495
+
1496
+ include CommonConstants
1497
+ include ModuleFunctions
1498
+
1499
+ dsl_accessor :alpha_level, 0.05
1500
+
1501
+ dsl_accessor :window_size, 10
1502
+
1503
+ dsl_accessor :slope_angle, ModuleFunctions.angle(0.1)
1504
+
1505
+ dsl_accessor :enabled, false
1506
+
1507
+ def initialize(enable, &block)
1508
+ if block
1509
+ enable.nil? or raise ArgumentError, "block form doesn't take an argument"
1510
+ instance_eval(&block)
1511
+ enabled true
1512
+ else
1513
+ enabled enable.nil? ? false : enable
1514
+ end
1515
+ end
1516
+ end
1517
+
1518
+ def truncate_data(enable = nil, &block)
1519
+ @truncate_data ||= TruncateData.new(enable, &block)
1520
+ end
1521
+
1522
+ constant :aggressive, :aggressive
1523
+
1524
+ class Covering
1525
+ extend DSLKit::DSLAccessor
1526
+
1527
+ dsl_accessor :alpha_level, 0.05
1528
+
1529
+ dsl_accessor :beta_level, 0.05
1530
+
1531
+ def initialize(&block)
1532
+ block and instance_eval(&block)
1533
+ end
1534
+ end
1535
+
1536
+ def covering(&block)
1537
+ @covering = Covering.new(&block)
1538
+ end
1539
+
1540
+ dsl_accessor :data_file, false
1541
+
1542
+ dsl_accessor :output_dir, Dir.pwd
1543
+
1544
+ class Histogram
1545
+ extend DSLKit::DSLAccessor
1546
+ extend DSLKit::Constant
1547
+
1548
+ include CommonConstants
1549
+
1550
+ dsl_accessor :bins, 10
1551
+
1552
+ dsl_accessor :enabled, false
1553
+
1554
+ dsl_accessor :file, false
1555
+
1556
+ def initialize(enable = nil, &block)
1557
+ if block
1558
+ enable.nil? or raise ArgumentError, "block form doesn't take an argument"
1559
+ instance_eval(&block)
1560
+ enabled true
1561
+ else
1562
+ enabled enable.nil? ? true : enable
1563
+ end
1564
+ end
1565
+ end
1566
+
1567
+ def histogram(enable = nil, &block)
1568
+ @histogram ||= Histogram.new(enable, &block)
1569
+ end
1570
+
1571
+ dsl_accessor :detect_outliers, true
1572
+
1573
+ dsl_accessor :outliers_factor, 3.0
1574
+
1575
+ class Autocorrelation
1576
+ extend DSLKit::DSLAccessor
1577
+ extend DSLKit::Constant
1578
+
1579
+ include CommonConstants
1580
+
1581
+ dsl_accessor :n_limit, 50
1582
+
1583
+ dsl_accessor :alpha_level, 0.05
1584
+
1585
+ dsl_accessor :max_lags, 20
1586
+
1587
+ dsl_accessor :enabled, false
1588
+
1589
+ dsl_accessor :file, true
1590
+
1591
+ def initialize(enable = nil, &block)
1592
+ if block
1593
+ enable.nil? or raise ArgumentError, "block form doesn't take an argument"
1594
+ instance_eval(&block)
1595
+ enabled true
1596
+ else
1597
+ enabled enable.nil? ? false : enable
1598
+ end
1599
+ end
1600
+ end
1601
+
1602
+ def autocorrelation(enable = nil, &block)
1603
+ @autocorrelation ||= Autocorrelation.new(enable, &block)
1604
+ end
1605
+
1606
+ dsl_accessor :linear_regression, true
1607
+
1608
+ include ModuleFunctions
1609
+ end
1610
+
1611
+ class << self
1612
+ def inherited(klass)
1613
+ klass.extend CaseExtension
1614
+ end
1615
+
1616
+ extend DSLKit::DSLAccessor
1617
+
1618
+ dsl_reader :cases, []
1619
+
1620
+ dsl_accessor :output, STDOUT
1621
+
1622
+ def output_filename(name)
1623
+ path = File.expand_path(name, output_dir)
1624
+ output File.new(path, 'a+')
1625
+ end
1626
+
1627
+ # Returns the total number of run counts +run_count+.
1628
+ def run_count
1629
+ cases.inject(0) { |s, c| s + c.run_count }
1630
+ end
1631
+
1632
+ dsl_accessor :autorun, true
1633
+
1634
+ # Iterate over all subclasses of class Case.
1635
+ def each(&block)
1636
+ cases.each(&block)
1637
+ end
1638
+
1639
+ # Run all subclasses' instances, that is all Bullshit cases, unless they
1640
+ # already have run.
1641
+ def run_all
1642
+ each do |bc_class|
1643
+ bc_class.run_count == 0 and bc_class.run
1644
+ end
1645
+ end
1646
+
1647
+ # Autorun all subclasses' instances, that is all Bullshit cases. If its
1648
+ # autorun dsl_accessor is false or it has already run, don't run the
1649
+ # case.
1650
+ def autorun_all
1651
+ each do |bc_class|
1652
+ bc_class.autorun and bc_class.run_count == 0 and bc_class.run
1653
+ end
1654
+ end
1655
+ end
1656
+
1657
+ # Returns a Case instance, that is used to run benchmark methods and
1658
+ # measure their running time.
1659
+ def initialize
1660
+ @clocks = []
1661
+ @comparison = Comparison.new
1662
+ @comparison.output self.class.output
1663
+ end
1664
+
1665
+ # Return the name of the benchmark case as a string.
1666
+ def to_s
1667
+ self.class.benchmark_name
1668
+ end
1669
+
1670
+ # Return all benchmark methods of this Case instance lexicographically
1671
+ # sorted.
1672
+ def self.sorted_bmethods
1673
+ instance_methods.map { |x| x.to_s }.grep(/\Abenchmark_/).sort
1674
+ end
1675
+
1676
+ # Return all benchmark methods of this Case instance in a random order.
1677
+ def bmethods
1678
+ unless @bmethods
1679
+ @bmethods = self.class.sorted_bmethods.sort_by do
1680
+ rand
1681
+ end
1682
+ @bmethods.map! { |n| CaseMethod.new(n, self) }
1683
+ end
1684
+ @bmethods
1685
+ end
1686
+
1687
+ # Return the CaseMethod instance for +method_name+ or nil, if there isn't
1688
+ # any method of this name.
1689
+ def [](method_name)
1690
+ method_name = "benchmark_#{method_name}"
1691
+ bmethods.find { |bm| bm.name == method_name }
1692
+ end
1693
+
1694
+ # Return the length of the longest_name of all these methods' names.
1695
+ def longest_name
1696
+ bmethods.empty? and return 0
1697
+ bmethods.map { |x| x.short_name.size }.max
1698
+ end
1699
+
1700
+ # Run benchmark case once and output results.
1701
+ def run_once
1702
+ self.class.run_count(self.class.run_count + 1)
1703
+ self.class.output.puts Time.now.strftime(' %FT%T %Z ').center(COLUMNS, '=')
1704
+ self.class.output.puts "Benchmarking on #{RUBY_DESCRIPTION}."
1705
+ self.class.output.puts self.class.message
1706
+ self.class.output.puts '=' * COLUMNS, ''
1707
+ @clocks.clear
1708
+ if self.class.warmup == :aggressive
1709
+ self.class.output.puts "Aggressively run all benchmarks for warmup first.", ''
1710
+ bmethods.each do |bc_method|
1711
+ GC.start
1712
+ clock = run_method bc_method
1713
+ self.class.output.puts evaluation(clock)
1714
+ GC.start
1715
+ end
1716
+ self.class.output.puts "Aggressive warmup done.", '', '=' * COLUMNS, ''
1717
+ end
1718
+ first = true
1719
+ bmethods.each do |bc_method|
1720
+ if first
1721
+ first = false
1722
+ else
1723
+ self.class.output.puts '-' * COLUMNS, ''
1724
+ end
1725
+ if self.class.warmup
1726
+ self.class.output.puts "This first run is only for warmup."
1727
+ GC.start
1728
+ clock = run_method bc_method
1729
+ self.class.output.puts evaluation(clock)
1730
+ GC.start
1731
+ end
1732
+ clock = run_method(bc_method)
1733
+ if self.class.truncate_data.enabled
1734
+ message = ''
1735
+ offset = clock.find_truncation_offset
1736
+ if clock.case.data_file
1737
+ slopes_file_path = clock.file_path 'slopes'
1738
+ message << "Writing slopes data file '#{slopes_file_path}'.\n"
1739
+ File.open(slopes_file_path, 'w') do |slopes_file|
1740
+ slopes_file.puts %w[#scatter slope] * "\t"
1741
+ slopes_file.puts clock.slopes.map { |s| s * "\t" }
1742
+ end
1743
+ end
1744
+ case offset
1745
+ when 0
1746
+ message << "No initial data truncated.\n =>"\
1747
+ " System may have been in a steady state from the beginning."
1748
+ when clock.repeat
1749
+ message << "After truncating measurements no data would have"\
1750
+ " remained.\n => No steady state could be detected."
1751
+ else
1752
+ if clock.case.data_file
1753
+ data_file_path = clock.file_path 'untruncated'
1754
+ message << "Writing untruncated measurement data file '#{data_file_path}'.\n"
1755
+ File.open(data_file_path, 'w') do |data_file|
1756
+ data_file.puts clock.class.to_a * "\t"
1757
+ data_file.puts clock.to_a.map { |times| times * "\t" }
1758
+ end
1759
+ end
1760
+ remaining = clock.repeat - offset
1761
+ offset_percentage = 100 * offset.to_f / clock.repeat
1762
+ message << sprintf("Truncated initial %u measurements: "\
1763
+ "%u -> %u (-%0.2f%%).\n", offset, clock.repeat, remaining,
1764
+ offset_percentage)
1765
+ clock.truncate_data(offset)
1766
+ end
1767
+ self.class.output.puts evaluation(clock), message
1768
+ else
1769
+ self.class.output.puts evaluation(clock)
1770
+ end
1771
+ @clocks << clock
1772
+ @comparison.benchmark(self, bc_method.short_name, :run => false)
1773
+ end
1774
+ @clocks
1775
+ end
1776
+
1777
+ # The clock instances, that were used during a run of this benchmark case.
1778
+ attr_reader :clocks
1779
+
1780
+ # Setup, run all benchmark cases (warmup and the real run) and output
1781
+ # results, run method speed comparisons, and teardown.
1782
+ def run(comparison = true)
1783
+ old_sync, self.class.output.sync = self.class.output.sync, true
1784
+ $DEBUG and warn "Calling setup."
1785
+ setup
1786
+ run_once
1787
+ comparison and @comparison.display
1788
+ self
1789
+ rescue => e
1790
+ warn "Caught #{e.class}: #{e}\n\n#{e.backtrace.map { |x| "\t#{x}\n" }}"
1791
+ ensure
1792
+ $DEBUG and warn "Calling teardown."
1793
+ teardown
1794
+ @clocks and write_files
1795
+ self.class.output.sync = old_sync
1796
+ end
1797
+
1798
+ # Creates an instance of this class and run it.
1799
+ def self.run
1800
+ new.run
1801
+ end
1802
+
1803
+ # Write all output files after run.
1804
+ def write_files
1805
+ for clock in @clocks
1806
+ if clock.case.data_file data_file_path = clock.file_path
1807
+ self.class.output.puts "Writing measurement data file '#{data_file_path}'."
1808
+ File.open(data_file_path, 'w') do |data_file|
1809
+ data_file.puts clock.class.to_a * "\t"
1810
+ data_file.puts clock.to_a.map { |times| times * "\t" }
1811
+ end
1812
+ end
1813
+ if clock.case.histogram.enabled and clock.case.histogram.file
1814
+ histogram_file_path = clock.file_path 'histogram'
1815
+ self.class.output.puts "Writing histogram file '#{histogram_file_path}'."
1816
+ File.open(histogram_file_path, 'w') do |data_file|
1817
+ data_file.puts %w[#binleft frequency binright] * "\t"
1818
+ data_file.puts clock.histogram(clock.case.compare_time).to_a.map { |times| times * "\t" }
1819
+ end
1820
+ end
1821
+ if clock.case.autocorrelation.enabled and clock.case.autocorrelation.file
1822
+ ac_plot_file_path = clock.file_path 'autocorrelation'
1823
+ self.class.output.puts "Writing autocorrelation plot file '#{ac_plot_file_path}'."
1824
+ File.open(ac_plot_file_path, 'w') do |data_file|
1825
+ data_file.puts %w[#lag autocorrelation] * "\t"
1826
+ data_file.puts clock.autocorrelation_plot(clock.case.compare_time).to_a.map { |ac| ac * "\t" }
1827
+ end
1828
+ end
1829
+ end
1830
+ end
1831
+
1832
+ # Output before +bc_method+ is run.
1833
+ def pre_run(bc_method)
1834
+ setup_name = bc_method.setup_name
1835
+ if respond_to? setup_name
1836
+ $DEBUG and warn "Calling #{setup_name}."
1837
+ __send__(setup_name)
1838
+ end
1839
+ self.class.output.puts "#{bc_method.long_name}:"
1840
+ end
1841
+
1842
+ # Run only pre_run and post_run methods. Yield to the block, if one was
1843
+ # given.
1844
+ def run_method(bc_method)
1845
+ pre_run bc_method
1846
+ clock = self.class.clock.__send__(self.class.clock_method, bc_method) do
1847
+ __send__(bc_method.name)
1848
+ end
1849
+ bc_method.clock = clock
1850
+ post_run bc_method
1851
+ clock
1852
+ end
1853
+
1854
+ # This method has to be implemented in subclasses, it should return the
1855
+ # evaluation results of the benchmarks as a string.
1856
+ def evaluation(clock)
1857
+ raise NotImplementedError, "has to be implemented in subclasses"
1858
+ end
1859
+
1860
+ # Output after +bc_method+ is run.
1861
+ def post_run(bc_method)
1862
+ teardown_name = bc_method.teardown_name
1863
+ if respond_to? teardown_name
1864
+ $DEBUG and warn "Calling #{teardown_name}."
1865
+ __send__(bc_method.teardown_name)
1866
+ end
1867
+ end
1868
+
1869
+ # General setup for all the benchmark methods.
1870
+ def setup
1871
+ end
1872
+
1873
+ # General teardown for all the benchmark methods.
1874
+ def teardown
1875
+ end
1876
+ end
1877
+
1878
+ # This module contains methods, that can be used in the evaluation method of
1879
+ # benchmark cases.
1880
+ module EvaluationModule
1881
+ def statistics_table(clock)
1882
+ result = ' ' * NAME_COLUMN_SIZE << ('%17s ' * 4) % times << "\n"
1883
+ result << evaluation_line('sum', times.map { |t| clock.__send__(t) })
1884
+ result << evaluation_line('min', times.map { |t| clock.min(t) })
1885
+ result << evaluation_line('std-', times.map { |t| clock.arithmetic_mean(t) - clock.sample_standard_deviation(t) })
1886
+ result << evaluation_line('mean', times.map { |t| clock.arithmetic_mean(t) })
1887
+ result << evaluation_line('std+', times.map { |t| clock.arithmetic_mean(t) + clock.sample_standard_deviation(t) })
1888
+ result << evaluation_line('max', times.map { |t| clock.max(t) })
1889
+ result << evaluation_line('std', times.map { |t| clock.sample_standard_deviation(t) })
1890
+ result << evaluation_line('std%', times.map { |t| clock.sample_standard_deviation_percentage(t) })
1891
+ result << evaluation_line('harm', times.map { |t| clock.harmonic_mean(t) })
1892
+ result << evaluation_line('geo', times.map { |t| clock.geometric_mean(t) })
1893
+ result << evaluation_line('q1', times.map { |t| clock.percentile(t, 25) })
1894
+ result << evaluation_line('med', times.map { |t| clock.median(t) })
1895
+ result << evaluation_line('q3', times.map { |t| clock.percentile(t, 75) })
1896
+ result << ' ' * NAME_COLUMN_SIZE << "%17u %17.5f %17.9f\n" % [ clock.repeat, clock.calls_mean, clock.call_time_mean ]
1897
+ result << ' ' * NAME_COLUMN_SIZE << "%17s %17s %17s\n" % %w[calls calls/sec secs/call]
1898
+ end
1899
+
1900
+ def histogram(clock)
1901
+ result = "\n"
1902
+ if clock.case.histogram.enabled
1903
+ clock.histogram(clock.case.compare_time).display(result, 50)
1904
+ end
1905
+ result
1906
+ end
1907
+
1908
+ def detect_outliers(clock)
1909
+ result = ''
1910
+ if clock.case.detect_outliers and
1911
+ outliers = clock.detect_outliers(clock.case.compare_time)
1912
+ then
1913
+ result << "\nOutliers detected with box plot algo "\
1914
+ "(median=%.5f, iqr=%.5f, factor=%.2f):\n" % outliers.values_at(:median, :iqr, :factor)
1915
+ result << outliers.select { |n, |
1916
+ [ :very_low, :low, :high, :very_high ].include?(n)
1917
+ }.map { |n, v| "#{n}=#{v}" } * ' '
1918
+ result << "\n"
1919
+ else
1920
+ result << "\nNo outliers detected with box plot algo.\n"
1921
+ end
1922
+ result
1923
+ end
1924
+
1925
+ def detect_autocorrelation(clock)
1926
+ result = ''
1927
+ clock.case.autocorrelation.enabled or return result
1928
+ if r = clock.detect_autocorrelation(clock.case.compare_time)
1929
+ result << "\nLjung-Box statistics: q=%.5f (alpha=#{r[:alpha_level]},"\
1930
+ " df=#{r[:lags]}).\n" % r[:q]
1931
+ if r[:detected]
1932
+ result << "%.5f >= %.5f => Autocorrelation was detected.\n" %
1933
+ [ r[:p], 1 - r[:alpha_level] ]
1934
+ else
1935
+ result << "%.5f < %.5f => No autocorrelation was detected.\n" %
1936
+ [ r[:p], 1 - r[:alpha_level] ]
1937
+ end
1938
+ else
1939
+ result << "\nDidn't have enough lags to compute Ljung-Box statistics.\n"
1940
+ end
1941
+ result
1942
+ end
1943
+
1944
+ private
1945
+
1946
+ def evaluation_line(name, values)
1947
+ name.ljust(NAME_COLUMN_SIZE) << ('%17.9f ' * 4) % values << "\n"
1948
+ end
1949
+
1950
+ def times
1951
+ self.class.clock.times
1952
+ end
1953
+ end
1954
+
1955
+ # This is a Benchmarking Case that uses a time limit.
1956
+ class TimeCase < Case
1957
+ include EvaluationModule
1958
+
1959
+ class << self
1960
+ extend DSLKit::DSLAccessor
1961
+ extend DSLKit::Constant
1962
+
1963
+ constant :clock_method, :time
1964
+
1965
+ dsl_accessor :duration
1966
+
1967
+ dsl_accessor :run_count, 0
1968
+
1969
+ def message
1970
+ "Running '#{self}' for a duration of #{duration} secs/method"\
1971
+ " (compare_time=#{compare_time}):"
1972
+ end
1973
+
1974
+ dsl_accessor(:output) { ::Bullshit::Case.output }
1975
+ end
1976
+
1977
+ # Returns the evaluation for +bullshit_case+ with the results of the
1978
+ # benchmarking as a String.
1979
+ def evaluation(clock)
1980
+ clock.repeat == 0 and
1981
+ raise BullshitException, "no measurements were gauged"
1982
+ result = ''
1983
+ result << statistics_table(clock)
1984
+ result << histogram(clock)
1985
+ result << detect_outliers(clock)
1986
+ result << detect_autocorrelation(clock)
1987
+ result << "\n"
1988
+ end
1989
+
1990
+ # Check if duration has been set. If yes call Case#run, otherwise raise a
1991
+ # BullshitException exception.
1992
+ def run(*)
1993
+ self.class.duration or
1994
+ raise BullshitException, 'duration has to be set'
1995
+ super
1996
+ end
1997
+ end
1998
+
1999
+ # This is a Benchmarking Case that uses a repetition limit.
2000
+ class RepeatCase < Case
2001
+ include EvaluationModule
2002
+
2003
+ class << self
2004
+ extend DSLKit::DSLAccessor
2005
+ extend DSLKit::Constant
2006
+
2007
+ constant :clock_method, :repeat
2008
+
2009
+ dsl_accessor :iterations
2010
+
2011
+ dsl_accessor :run_count, 0
2012
+
2013
+ def message
2014
+ "Running '#{self}' for #{iterations} iterations/method"\
2015
+ " (compare_time=#{compare_time})"
2016
+ end
2017
+
2018
+ dsl_accessor(:output) { ::Bullshit::Case.output }
2019
+ end
2020
+
2021
+ # Returns the evaluation for +bullshit_case+ with the results of the
2022
+ # benchmarking as a String.
2023
+ def evaluation(clock)
2024
+ clock.repeat == 0 and
2025
+ raise BullshitException, "no measurements were gauged"
2026
+ result = ''
2027
+ result << statistics_table(clock)
2028
+ result << histogram(clock)
2029
+ result << detect_outliers(clock)
2030
+ result << detect_autocorrelation(clock)
2031
+ result << "\n"
2032
+ end
2033
+
2034
+ # vn heck if iterations has been set. If yes call Case#run, otherwise raise a
2035
+ # BullshitException exception.
2036
+ def run(*)
2037
+ self.class.iterations or
2038
+ raise BullshitException, 'iterations have to be set'
2039
+ super
2040
+ end
2041
+ end
2042
+
2043
+ # A range case is a benchmark case, where each iteration depends on a
2044
+ # different argument, which might cause a non-constant running time. The
2045
+ # evaluation of the case doesn't check for constancy and steady-state
2046
+ # properties.
2047
+ class RangeCase < Case
2048
+ include EvaluationModule
2049
+
2050
+ class << self
2051
+ extend DSLKit::DSLAccessor
2052
+ extend DSLKit::Constant
2053
+
2054
+ constant :clock_method, :scale_range
2055
+
2056
+ dsl_accessor :range
2057
+
2058
+ dsl_accessor :scatter, 1
2059
+
2060
+ dsl_accessor :run_count, 0
2061
+
2062
+ def message
2063
+ "Running '#{self}' for range #{range.inspect}"\
2064
+ " (compare_time=#{compare_time})"
2065
+ end
2066
+
2067
+ dsl_accessor(:output) { ::Bullshit::Case.output }
2068
+
2069
+ attr_accessor :args
2070
+ end
2071
+
2072
+ def args
2073
+ self.class.args
2074
+ end
2075
+ private :args
2076
+
2077
+ # Returns the evaluation for +bullshit_case+ with the results of the
2078
+ # benchmarking as a String.
2079
+ def evaluation(clock)
2080
+ clock.repeat == 0 and
2081
+ raise BullshitException, "no measurements were gauged"
2082
+ result = ''
2083
+ result << statistics_table(clock)
2084
+ result << "\n"
2085
+ end
2086
+
2087
+ # Check if iterations has been set. If yes call Case#run, otherwise raise a
2088
+ # BullshitException exception.
2089
+ def run(*)
2090
+ self.class.range or
2091
+ raise BullshitException,
2092
+ 'range has to be set to an enumerable of arguments'
2093
+ super
2094
+ end
2095
+ end
2096
+
2097
+ # A Comparison instance compares the benchmark results of different
2098
+ # case methods and outputs the results.
2099
+ class Comparison
2100
+ extend DSLKit::Constant
2101
+ extend DSLKit::DSLAccessor
2102
+
2103
+ include CommonConstants
2104
+
2105
+ dsl_accessor :output, STDOUT
2106
+
2107
+ dsl_accessor :output_dir, Dir.pwd
2108
+
2109
+ # Output results to the file named +name+.
2110
+ def output_filename(name)
2111
+ path = File.expand_path(name, output_dir)
2112
+ output File.new(path, 'a+')
2113
+ end
2114
+
2115
+ # Return a comparison object configured by +block+.
2116
+ def initialize(&block)
2117
+ @cases = {}
2118
+ @benchmark_methods = []
2119
+ block and instance_eval(&block)
2120
+ end
2121
+
2122
+ # Benchmark case method +method+ of +bc_class+. Options are:
2123
+ # * :run to not run this +bc_class+ if set to false (defaults to true),
2124
+ # * :load to load the data of a previous run for this +method+, if set to
2125
+ # true. If the true value is a file path string, load the data from the
2126
+ # given file at the path.
2127
+ def benchmark(bc_class, method, opts = {})
2128
+ opts = { :run => true, :combine => true }.merge opts
2129
+ if Case === bc_class
2130
+ bullshit_case, bc_class = bc_class, bullshit_case.class
2131
+ @cases[bc_class] ||= []
2132
+ if opts[:combine]
2133
+ if @cases[bc_class].empty?
2134
+ @cases[bc_class] << bullshit_case
2135
+ else
2136
+ bullshit_case = @cases[bc_class].first
2137
+ end
2138
+ else
2139
+ @cases[bc_class] << bullshit_case
2140
+ end
2141
+ else
2142
+ @cases[bc_class] ||= []
2143
+ if opts[:combine]
2144
+ unless bullshit_case = @cases[bc_class].first
2145
+ bullshit_case = bc_class.new
2146
+ @cases[bc_class] << bullshit_case
2147
+ end
2148
+ else
2149
+ bullshit_case = bc_class.new
2150
+ @cases[bc_class] << bullshit_case
2151
+ end
2152
+ end
2153
+ bc_method = bullshit_case[method] or raise BullshitException,
2154
+ "unknown benchmark method #{bc_class}##{method}"
2155
+ if comment = opts[:comment]
2156
+ bc_method.comment = comment
2157
+ end
2158
+ @benchmark_methods << bc_method
2159
+ if file_path = opts[:load]
2160
+ if file_path != true
2161
+ bc_method.load file_path
2162
+ else
2163
+ bc_method.load
2164
+ end
2165
+ else
2166
+ opts[:run] and bullshit_case.run false
2167
+ end
2168
+ nil
2169
+ end
2170
+
2171
+ # Return all benchmark methods for all the cached benchmark cases.
2172
+ attr_reader :benchmark_methods
2173
+
2174
+ # Return all benchmark methods ordered by the result of comparator call to
2175
+ # their clock values.
2176
+ def compare_methods(comparator)
2177
+ benchmark_methods.sort_by { |m| m.clock.__send__(comparator) }
2178
+ end
2179
+
2180
+ # Return the length of the longest name of all benchmark methods.
2181
+ def longest_name_size
2182
+ benchmark_methods.map { |m| m.long_name.size }.max
2183
+ end
2184
+
2185
+ # Returns the prefix_string for a method speed comparison in the output.
2186
+ def prefix_string(method)
2187
+ "% -#{longest_name_size}s %u repeats:" %
2188
+ [ method.long_name , method.clock.repeat ]
2189
+ end
2190
+
2191
+ # Output all speed comparisons between methods.
2192
+ def display
2193
+ output.puts Time.now.strftime(' %FT%T %Z ').center(COLUMNS, '=')
2194
+ for comparator in [ :call_time_mean, :call_time_median ]
2195
+ output.puts
2196
+ methods = compare_methods(comparator)
2197
+ methods.size < 2 and return
2198
+ max = methods.last.clock.__send__(comparator)
2199
+ output.puts "Comparing times (#{comparator}):"
2200
+ methods.each_with_index do |m, i|
2201
+ covers = []
2202
+ for x in methods
2203
+ if m != x and m.cover?(x)
2204
+ j = 0
2205
+ if methods.find { |y| j += 1; x == y }
2206
+ my_case = m.case.class
2207
+ iterations = m.clock.analysis[my_case.compare_time].suggested_sample_size(
2208
+ x.clock.analysis[my_case.compare_time], my_case.covering.alpha_level, my_case.covering.beta_level)
2209
+ if iterations.nan? or iterations.infinite?
2210
+ covers << "#{j} (?)"
2211
+ else
2212
+ min_iterations = iterations.ceil
2213
+ min_iterations >= 1E6 and min_iterations = "%f" % (1 / 0.0)
2214
+ covers << "#{j} (>=#{min_iterations})"
2215
+ end
2216
+ end
2217
+ end
2218
+ end
2219
+ covers *= ', '
2220
+ output.printf\
2221
+ "% 2u #{prefix_string(m)}\n %17.9f"\
2222
+ " (%#{::Bullshit::Clock::TIMES_MAX}s) -> %8.3fx %s\n"\
2223
+ " %17.9f\n",
2224
+ i + 1, m.clock.calls(comparator), m.case.class.compare_time,
2225
+ max / m.clock.__send__(comparator), covers, m.clock.__send__(comparator)
2226
+ end
2227
+ output.puts " %17s (%#{::Bullshit::Clock::TIMES_MAX}s) -> %8s %s\n"\
2228
+ " %17s\n"\
2229
+ % %w[calls/sec time speed covers secs/call]
2230
+ end
2231
+ output.puts '=' * COLUMNS
2232
+ end
2233
+ end
2234
+
2235
+ # Create a Comparison instance configured by +block+ and compute and display
2236
+ # the results.
2237
+ def self.compare(&block)
2238
+ Comparison.new(&block).display
2239
+ end
2240
+
2241
+ at_exit do
2242
+ Case.autorun and Case.run_all
2243
+ end
2244
+ end