benchmarker 0.1.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,1041 @@
1
+ # -*- coding: utf-8 -*-
2
+ # frozen_string_literal: true
3
+
4
+ ###
5
+ ### $Release: 1.0.0 $
6
+ ### $Copyright: copyright(c) 2010-2021 kuwata-lab.com all rights reserved $
7
+ ### $License: MIT License $
8
+ ###
9
+
10
+
11
+ module Benchmarker
12
+
13
+
14
+ VERSION = "$Release: 1.0.0 $".split()[1]
15
+
16
+ N_REPEAT = 100 # number of repeat (100 times)
17
+
18
+ OPTIONS = {} # ex: {loop: 1000, iter: 10, extra: 2, inverse: true}
19
+
20
+ def self.new(title=nil, **kwargs, &b)
21
+ #; [!s7y6x] overwrites existing options by command-line options.
22
+ kwargs.update(OPTIONS)
23
+ #; [!2zh7w] creates new Benchmark object wit options.
24
+ bm = Benchmark.new(title: title, **kwargs)
25
+ return bm
26
+ end
27
+
28
+ def self.scope(title=nil, **kwargs, &block)
29
+ #; [!4f695] creates Benchmark object, define tasks, and run them.
30
+ bm = self.new(title, **kwargs)
31
+ bm.scope(&block)
32
+ bm.run()
33
+ return bm
34
+ end
35
+
36
+
37
+ class Benchmark
38
+
39
+ def initialize(title: nil, width: 30, loop: 1, iter: 1, extra: 0, inverse: false, outfile: nil, quiet: false, colorize: nil, sleep: nil, filter: nil)
40
+ @title = title
41
+ @width = width || 30
42
+ @loop = loop || 1
43
+ @iter = iter || 1
44
+ @extra = extra || 0
45
+ @inverse = inverse || false
46
+ @outfile = outfile
47
+ @quiet = quiet || false
48
+ @colorize = colorize
49
+ @sleep = sleep
50
+ @filter = filter
51
+ if filter
52
+ #; [!0mz0f] error when filter string is invalid format.
53
+ filter =~ /^(task|tag)(!?=+)(.*)/ or
54
+ raise ArgumentError.new("#{filter}: invalid filter.")
55
+ #; [!xo7bq] error when filter operator is invalid.
56
+ $2 == '=' || $2 == '!=' or
57
+ raise ArgumentError.new("#{filter}: expected operator is '=' or '!='.")
58
+ end
59
+ @entries = [] # [[Task, Resutl]]
60
+ @jdata = {}
61
+ @hooks = {} # {before: Proc, after: Proc, ...}
62
+ @empty_task = nil
63
+ end
64
+
65
+ attr_reader :title, :width, :loop, :iter, :extra, :inverse, :outfile, :quiet, :colorize, :sleep, :filter
66
+
67
+ def clear()
68
+ #; [!phqdn] clears benchmark result and JSON data.
69
+ @entries.each {|_, result| result.clear() }
70
+ @jdata = {}
71
+ self
72
+ end
73
+
74
+ def scope(&block)
75
+ #; [!wrjy0] creates wrapper object and yields block with it as self.
76
+ #; [!6h24d] passes benchmark object as argument of block.
77
+ scope = Scope.new(self)
78
+ scope.instance_exec(self, &block)
79
+ #; [!y0uwr] returns self.
80
+ self
81
+ end
82
+
83
+ def define_empty_task(code=nil, tag: nil, skip: nil, &block) # :nodoc:
84
+ #; [!qzr1s] error when called more than once.
85
+ @empty_task.nil? or
86
+ raise "cannot define empty task more than once."
87
+ #; [!w66xp] creates empty task.
88
+ @empty_task = TASK.new(nil, code, tag: tag, skip: skip, &block)
89
+ return @empty_task
90
+ end
91
+
92
+ def define_task(name, code=nil, tag: nil, skip: nil, &block) # :nodoc:
93
+ #; [!re6b8] creates new task.
94
+ #; [!r8o0p] can take a tag.
95
+ task = TASK.new(name, code, tag: tag, skip: skip, &block)
96
+ @entries << [task, Result.new]
97
+ return task
98
+ end
99
+
100
+ def define_hook(key, &block)
101
+ #; [!2u53t] register proc object with symbol key.
102
+ @hooks[key] = block
103
+ self
104
+ end
105
+
106
+ def call_hook(key, *args)
107
+ #; [!0to2s] calls hook with arguments.
108
+ fn = @hooks[key]
109
+ fn.call(*args) if fn
110
+ end
111
+ private :call_hook
112
+
113
+ def run(warmup: false)
114
+ #; [!0fo0l] runs benchmark tasks and reports result.
115
+ report_environment()
116
+ filter_tasks()
117
+ #; [!2j4ks] calls 'before_all' hook.
118
+ call_hook(:before_all)
119
+ begin
120
+ if warmup
121
+ #; [!6h26u] runs preriminary round when `warmup: true` provided.
122
+ _ignore_output { invoke_tasks() }
123
+ clear()
124
+ end
125
+ invoke_tasks()
126
+ #; [!w1rq7] calls 'after_all' hook even if error raised.
127
+ ensure
128
+ call_hook(:after_all)
129
+ end
130
+ ignore_skipped_tasks()
131
+ report_minmax()
132
+ report_average()
133
+ report_stats()
134
+ write_outfile()
135
+ nil
136
+ end
137
+
138
+ private
139
+
140
+ def _ignore_output(&b)
141
+ #; [!wazs7] ignores output in block argument.
142
+ require 'stringio'
143
+ bkup, $stdout = $stdout, StringIO.new
144
+ begin
145
+ yield
146
+ ensure
147
+ $stdout = bkup
148
+ end
149
+ end
150
+
151
+ def filter_tasks()
152
+ #; [!g207d] do nothing when filter string is not provided.
153
+ if @filter
154
+ #; [!f1n1v] filters tasks by task name when filer string is 'task=...'.
155
+ #; [!m79cf] filters tasks by tag value when filer string is 'tag=...'.
156
+ @filter =~ /^(task|tag)(=|!=)(.*)/ or raise "** internal error"
157
+ key = $1; op = $2; pattern = $3
158
+ @entries = @entries.select {|task, _|
159
+ val = key == 'tag' ? task.tag : task.name
160
+ if val
161
+ bool = [val].flatten.any? {|v| File.fnmatch(pattern, v, File::FNM_EXTGLOB) }
162
+ else
163
+ bool = false
164
+ end
165
+ #; [!0in0q] supports negative filter by '!=' operator.
166
+ op == '!=' ? !bool : bool
167
+ }
168
+ end
169
+ nil
170
+ end
171
+
172
+ def invoke_tasks()
173
+ @jdata[:Results] = []
174
+ #; [!c8yak] invokes tasks once if 'iter' option not specified.
175
+ #; [!unond] invokes tasks multiple times if 'iter' option specified.
176
+ #; [!wzvdb] invokes tasks 16 times if 'iter' is 10 and 'extra' is 3.
177
+ n = @iter + 2 * @extra
178
+ (1..n).each do |i|
179
+ @jdata[:Results] << (rows = [])
180
+ #; [!5axhl] prints result even on quiet mode if no 'iter' nor 'extra'.
181
+ quiet = @quiet && n != 1
182
+ #; [!yg9i7] prints result unless quiet mode.
183
+ #; [!94916] suppresses result if quiet mode.
184
+ #heading = n == 1 ? "##" : "## (##{i})"
185
+ if n == 1
186
+ heading = "##"
187
+ space = " " * (@width - heading.length)
188
+ else
189
+ heading = "## " + colorize_iter("(##{i})")
190
+ space = " " * (@width - "## (##{i})".length)
191
+ end
192
+ puts "" unless quiet
193
+ #puts "%-#{@width}s %9s %9s %9s %9s" % [heading, 'user', 'sys', 'total', 'real'] unless quiet
194
+ puts "%s%s %9s %9s %9s %9s" % [heading, space, 'user', 'sys', 'total', 'real'] unless quiet
195
+ #; [!3hgos] invokes empty task at first if defined.
196
+ if @empty_task && !@empty_task.skip?
197
+ empty_timeset = __invoke(@empty_task, "(Empty)", nil, quiet)
198
+ t = empty_timeset
199
+ s = "%9.4f %9.4f %9.4f %9.4f" % [t.user, t.sys, t.total, t.real]
200
+ #s = "%9.4f %9.4f %9.4f %s" % [t.user, t.sys, t.total, colorize_real('%9.4f' % t.real)]
201
+ puts s unless quiet
202
+ #; [!knjls] records result of empty loop into JSON data.
203
+ rows << ["(Empty)"] + empty_timeset.to_a.collect {|x| ('%9.4f' % x).to_f }
204
+ Kernel.sleep @sleep if @sleep
205
+ else
206
+ empty_timeset = nil
207
+ end
208
+ #; [!xf84h] invokes all tasks.
209
+ @entries.each do |task, result|
210
+ timeset = __invoke(task, task.name, @hooks[:validate], quiet)
211
+ #; [!dyofw] prints reason if 'skip:' option specified.
212
+ if task.skip?
213
+ reason = task.skip
214
+ result.skipped = reason
215
+ puts " # Skipped (reason: #{reason})" unless quiet
216
+ #; [!ygpx0] records reason of skip into JSON data.
217
+ rows << [task.name, nil, nil, nil, nil, reason]
218
+ next
219
+ end
220
+ #; [!513ok] subtract timeset of empty loop from timeset of each task.
221
+ if empty_timeset
222
+ timeset -= empty_timeset unless task.has_code?
223
+ timeset -= empty_timeset.div(N_REPEAT) if task.has_code?
224
+ end
225
+ t = timeset
226
+ #s = "%9.4f %9.4f %9.4f %9.4f" % [t.user, t.sys, t.total, t.real]
227
+ s = "%9.4f %9.4f %9.4f %s" % [t.user, t.sys, t.total, colorize_real('%9.4f' % t.real)]
228
+ puts s unless quiet
229
+ result.add(timeset)
230
+ #; [!ejxif] records result of each task into JSON data.
231
+ rows << [task.name] + timeset.to_a.collect {|x| ('%9.4f' % x).to_f }
232
+ #; [!vbhvz] sleeps N seconds after each task if `sleep` option specified.
233
+ Kernel.sleep @sleep if @sleep
234
+ end
235
+ end
236
+ nil
237
+ end
238
+ def __invoke(task, task_name, validator, quiet)
239
+ print "%-#{@width}s " % task_name unless quiet
240
+ $stdout.flush() unless quiet
241
+ #; [!fv4cv] skips task invocation if skip reason is specified.
242
+ return nil if task.skip?
243
+ #; [!hbass] calls 'before' hook with task name and tag.
244
+ call_hook(:before, task.name, task.tag)
245
+ #; [!6g36c] invokes task with validator if validator defined.
246
+ begin
247
+ timeset = task.invoke(@loop, &validator)
248
+ return timeset
249
+ #; [!7960c] calls 'after' hook with task name and tag even if error raised.
250
+ ensure
251
+ call_hook(:after, task_name, task.tag)
252
+ end
253
+ end
254
+
255
+ def ignore_skipped_tasks()
256
+ #; [!5gpo7] removes skipped tasks and leaves other tasks.
257
+ @entries = @entries.reject {|_, result| result.skipped? }
258
+ nil
259
+ end
260
+
261
+ def report_environment()
262
+ #; [!rx7nn] prints ruby version, platform, several options, and so on.
263
+ s = "loop=#{@loop.inspect}, iter=#{@iter.inspect}, extra=#{@extra.inspect}"
264
+ s += ", inverse=#{@inverse}" if @inverse
265
+ kvs = [["title", @title], ["options", s]] + Misc.environment_info()
266
+ puts kvs.collect {|k, v| "## %-16s %s\n" % ["#{k}:", v] }.join()
267
+ @jdata[:Environment] = Hash.new(kvs)
268
+ nil
269
+ end
270
+
271
+ def report_minmax()
272
+ if @extra > 0
273
+ rows = _remove_minmax()
274
+ puts _render_minmax(rows)
275
+ end
276
+ end
277
+
278
+ def _remove_minmax()
279
+ #; [!uxe7e] removes best and worst results if 'extra' option specified.
280
+ tuples = []
281
+ @entries.each do |task, result|
282
+ removed_list = result.remove_minmax(@extra)
283
+ tuples << [task.name, removed_list]
284
+ end
285
+ #; [!is6ll] returns removed min and max data.
286
+ rows = []
287
+ tuples.each do |task_name, removed_list|
288
+ removed_list.each_with_index do |(min_t, min_idx, max_t, max_idx), i|
289
+ task_name = nil if i > 0
290
+ min_t2 = ("%9.4f" % min_t).to_f
291
+ max_t2 = ("%9.4f" % max_t).to_f
292
+ rows << [task_name, min_t2, "(##{min_idx})", max_t2, "(##{max_idx})"]
293
+ end
294
+ end
295
+ #; [!xwddz] sets removed best and worst results into JSON data.
296
+ @jdata[:RemovedMinMax] = rows
297
+ return rows
298
+ end
299
+
300
+ def _render_minmax(rows)
301
+ #; [!p71ax] returns rendered string.
302
+ buf = ["\n"]
303
+ heading = "## Removed Min & Max"
304
+ buf << "%-#{@width+4}s %5s %9s %9s %9s\n" % [heading, 'min', 'iter', 'max', 'iter']
305
+ rows.each do |row|
306
+ #buf << "%-#{@width}s %9.4f %9s %9.4f %9s\n" % row
307
+ task_name, min_t, min_i, max_t, max_i = row
308
+ arr = [task_name, colorize_real('%9.4f' % min_t), colorize_iter('%9s' % min_i),
309
+ colorize_real('%9.4f' % max_t), colorize_iter('%9s' % max_i)]
310
+ buf << "%-#{@width}s %s %s %s %s\n" % arr
311
+ end
312
+ return buf.join()
313
+ end
314
+
315
+ def report_average()
316
+ if @iter > 1 || @extra > 0
317
+ rows = _calc_average()
318
+ puts _render_average(rows)
319
+ end
320
+ end
321
+
322
+ def _calc_average()
323
+ #; [!qu29s] calculates average of real times for each task.
324
+ rows = @entries.collect {|task, result|
325
+ avg_timeset = result.calc_average()
326
+ [task.name] + avg_timeset.to_a.collect {|x| ("%9.4f" % x).to_f }
327
+ }
328
+ #; [!jxf28] sets average results into JSON data.
329
+ @jdata[:Average] = rows
330
+ return rows
331
+ end
332
+
333
+ def _render_average(rows)
334
+ #; [!j9wlv] returns rendered string.
335
+ buf = ["\n"]
336
+ heading = "## Average of #{@iter}"
337
+ heading += " (=#{@iter + 2 * @extra}-2*#{@extra})" if @extra > 0
338
+ buf << "%-#{@width+4}s %5s %9s %9s %9s\n" % [heading, 'user', 'sys', 'total', 'real']
339
+ rows.each do |row|
340
+ #buf << "%-#{@width}s %9.4f %9.4f %9.4f %9.4f\n" % row
341
+ real = colorize_real('%9.4f' % row.pop())
342
+ buf << "%-#{@width}s %9.4f %9.4f %9.4f %s\n" % (row + [real])
343
+ end
344
+ return buf.join()
345
+ end
346
+
347
+ def report_stats()
348
+ #; [!0jn7d] sorts results by real sec.
349
+ pairs = @entries.collect {|task, result|
350
+ #real = @iter > 1 || @extra > 0 ? result.calc_average().real : result[0].real
351
+ real = result.calc_average().real
352
+ [task.name, real]
353
+ }
354
+ pairs = pairs.sort_by {|_, real| real }
355
+ print _render_ranking(pairs)
356
+ print _render_matrix(pairs)
357
+ end
358
+
359
+ def _render_ranking(pairs)
360
+ #; [!2lu55] calculates ranking data and sets it into JSON data.
361
+ rows = []
362
+ base = nil
363
+ pairs.each do |task_name, sec|
364
+ base ||= sec
365
+ percent = 100.0 * base / sec
366
+ barchart = '*' * (percent / 5.0).round() # max 20 chars (=100%)
367
+ loop = @inverse == true ? (@loop || 1) : (@inverse || @loop || 1)
368
+ rows << [task_name, ("%.4f" % sec).to_f, "%.1f%%" % percent,
369
+ "%.2f times/sec" % (loop / sec), barchart]
370
+ end
371
+ @jdata[:Ranking] = rows
372
+ #; [!55x8r] returns rendered string of ranking.
373
+ buf = ["\n"]
374
+ heading = "## Ranking"
375
+ if @inverse
376
+ buf << "%-#{@width}s %9s%30s\n" % [heading, 'real', 'times/sec']
377
+ else
378
+ buf << "%-#{@width}s %9s\n" % [heading, 'real']
379
+ end
380
+ rows.each do |task_name, sec, percent, inverse, barchart|
381
+ s = @inverse ? "%20s" % inverse.split()[0] : barchart
382
+ #buf << "%-#{@width}s %9.4f (%6s) %s\n" % [task_name, sec, percent, s]
383
+ buf << "%-#{@width}s %s (%6s) %s\n" % [task_name, colorize_real('%9.4f' % sec), percent, s]
384
+ end
385
+ return buf.join()
386
+ end
387
+
388
+ def _render_matrix(pairs)
389
+ #; [!2lu55] calculates ranking data and sets it into JSON data.
390
+ rows = []
391
+ pairs.each_with_index do |(task_name, sec), i|
392
+ base = pairs[i][1]
393
+ row = ["[#{i+1}] #{task_name}", ("%9.4f" % sec).to_f]
394
+ pairs.each {|_, r| row << "%.1f%%" % (100.0 * r / base) }
395
+ rows << row
396
+ end
397
+ @jdata[:Matrix] = rows
398
+ #; [!rwfxu] returns rendered string of matrix.
399
+ buf = ["\n"]
400
+ heading = "## Matrix"
401
+ s = "%-#{@width}s %9s" % [heading, 'real']
402
+ (1..pairs.length).each {|i| s += " %8s" % "[#{i}]" }
403
+ buf << "#{s}\n"
404
+ rows.each do |task_name, real, *percents|
405
+ s = "%-#{@width}s %s" % [task_name, colorize_real('%9.4f' % real)]
406
+ percents.each {|p| s += " %8s" % p }
407
+ buf << "#{s}\n"
408
+ end
409
+ return buf.join()
410
+ end
411
+
412
+ def write_outfile()
413
+ #; [!o8ah6] writes result data into JSON file if 'outfile' option specified.
414
+ if @outfile
415
+ filename = @outfile
416
+ require 'json'
417
+ jstr = JSON.pretty_generate(@jdata, indent: ' ', space: ' ')
418
+ if filename == '-'
419
+ $stdout.puts(jstr)
420
+ else
421
+ File.write(filename, jstr)
422
+ end
423
+ jstr
424
+ end
425
+ end
426
+
427
+ def colorize?
428
+ #; [!cy10n] returns true if '-c' option specified.
429
+ #; [!e0gcz] returns false if '-C' option specified.
430
+ #; [!6v90d] returns result of `Color.colorize?` if neither '-c' nor '-C' specified.
431
+ return @colorize.nil? ? Color.colorize?() : @colorize
432
+ end
433
+
434
+ def colorize_real(s)
435
+ colorize?() ? Color.real(s) : s
436
+ end
437
+
438
+ def colorize_iter(s)
439
+ colorize?() ? Color.iter(s) : s
440
+ end
441
+
442
+ end
443
+
444
+
445
+ class Scope
446
+
447
+ def initialize(bm=nil)
448
+ @__bm = bm
449
+ end
450
+
451
+ def task(name, code=nil, binding=nil, tag: nil, skip: nil, &block)
452
+ #; [!843ju] when code argument provided...
453
+ if code
454
+ #; [!bwfak] code argument and block argument are exclusive.
455
+ ! block_given?() or
456
+ raise TaskError, "task(#{name.inspect}): cannot accept #{code.class} argument when block argument given."
457
+ #; [!4dm9q] generates block argument if code argument passed.
458
+ location = caller_locations(1, 1).first
459
+ defcode = "proc do #{(code+';') * N_REPEAT} end" # repeat code 100 times
460
+ binding ||= ::TOPLEVEL_BINDING
461
+ block = eval defcode, binding, location.path, location.lineno+1
462
+ end
463
+ #; [!kh7r9] define empty-loop task if name is nil.
464
+ return @__bm.define_empty_task(code, tag: tag, skip: skip, &block) if name.nil?
465
+ #; [!j6pmr] creates new task object.
466
+ return @__bm.define_task(name, code, tag: tag, skip: skip, &block)
467
+ end
468
+ alias report task # for compatibility with 'benchamrk.rb'
469
+
470
+ def empty_task(code=nil, binding=nil, &block)
471
+ #; [!ycoch] creates new empty-loop task object.
472
+ return task(nil, code, binding, &block)
473
+ end
474
+
475
+ def assert_eq(actual, expected, errmsg=nil)
476
+ #; [!8m6bh] do nothing if ectual == expected.
477
+ #; [!f9ey6] raises error unless actual == expected.
478
+ return if actual == expected
479
+ errmsg ||= "#{actual.inspect} == #{expected.inspect}: failed."
480
+ assert false, errmsg
481
+ end
482
+
483
+ def before(&block)
484
+ #; [!2ir4q] defines 'before' hook.
485
+ @__bm.define_hook(:before, &block)
486
+ end
487
+
488
+ def after(&block)
489
+ #; [!05up6] defines 'after' hook.
490
+ @__bm.define_hook(:after, &block)
491
+ end
492
+
493
+ def before_all(&block)
494
+ #; [!1oier] defines 'before_all' hook.
495
+ @__bm.define_hook(:before_all, &block)
496
+ end
497
+
498
+ def after_all(&block)
499
+ #; [!z7xop] defines 'after_all' hook.
500
+ @__bm.define_hook(:after_all, &block)
501
+ end
502
+
503
+ def validate(&block)
504
+ #; [!q2aev] defines validator.
505
+ return @__bm.define_hook(:validate, &block)
506
+ end
507
+
508
+ def assert(expr, errmsg)
509
+ #; [!a0c7e] do nothing if assertion succeeded.
510
+ #; [!5vmbc] raises error if assertion failed.
511
+ #; [!7vt5l] puts newline if assertion failed.
512
+ return if expr
513
+ puts ""
514
+ raise ValidationFailed, errmsg
515
+ rescue => exc
516
+ #; [!mhw59] makes error backtrace compact.
517
+ exc.backtrace.reject! {|x| x =~ /[\/\\:]benchmarker\.rb.*:/ }
518
+ raise
519
+ end
520
+
521
+ end
522
+
523
+
524
+ class ValidationFailed < StandardError
525
+ end
526
+
527
+
528
+ class TaskError < StandardError
529
+ end
530
+
531
+
532
+ class Task
533
+
534
+ def initialize(name, code=nil, tag: nil, skip: nil, &block)
535
+ @name = name
536
+ @code = code
537
+ @tag = tag
538
+ @skip = skip # reason to skip
539
+ @block = block
540
+ end
541
+
542
+ attr_reader :name, :tag, :skip, :block
543
+
544
+ def has_code?
545
+ return !!@code
546
+ end
547
+
548
+ def skip?
549
+ return !!@skip
550
+ end
551
+
552
+ def invoke(loop=1, &validator)
553
+ #; [!s2f6v] when task block is build from repeated code...
554
+ if @code
555
+ n_repeat = N_REPEAT # == 100
556
+ #; [!i2r8o] error when number of loop is less than 100.
557
+ loop >= n_repeat or
558
+ raise TaskError, "task(#{@name.inspect}): number of loop (=#{loop}) should be >= #{n_repeat}, but not."
559
+ #; [!kzno6] error when number of loop is not a multiple of 100.
560
+ loop % n_repeat == 0 or
561
+ raise TaskError, "task(#{@name.inspect}): number of loop (=#{loop}) should be a multiple of #{n_repeat}, but not."
562
+ #; [!gbukv] changes number of loop to 1/100.
563
+ loop = loop / n_repeat
564
+ end
565
+ #; [!frq25] kicks GC before calling task block.
566
+ GC.start()
567
+ #; [!tgql6] invokes block N times.
568
+ block = @block
569
+ t1 = Process.times
570
+ start_t = Time.now
571
+ while (loop -= 1) >= 0
572
+ ret = block.call()
573
+ end
574
+ end_t = Time.now
575
+ t2 = Process.times
576
+ #; [!zw4kt] yields validator with result value of block.
577
+ yield ret, @name, @tag if block_given?()
578
+ #; [!9e5pr] returns TimeSet object.
579
+ user = t2.utime - t1.utime
580
+ sys = t2.stime - t1.stime
581
+ total = user + sys
582
+ real = end_t - start_t
583
+ return TimeSet.new(user, sys, total, real)
584
+ end
585
+
586
+ end
587
+
588
+ TASK = Task
589
+
590
+
591
+ TimeSet = Struct.new('TimeSet', :user, :sys, :total, :real) do
592
+
593
+ def -(t)
594
+ #; [!cpwgf] returns new TimeSet object.
595
+ user = self.user - t.user
596
+ sys = self.sys - t.sys
597
+ total = self.total - t.total
598
+ real = self.real - t.real
599
+ return TimeSet.new(user, sys, total, real)
600
+ end
601
+
602
+ def div(n)
603
+ #; [!4o9ns] returns new TimeSet object which values are divided by n.
604
+ user = self.user / n
605
+ sys = self.sys / n
606
+ total = self.total / n
607
+ real = self.real / n
608
+ return TimeSet.new(user, sys, total, real)
609
+ end
610
+
611
+ end
612
+
613
+
614
+ class Result
615
+
616
+ def initialize()
617
+ @iterations = []
618
+ end
619
+
620
+ def [](idx)
621
+ return @iterations[idx]
622
+ end
623
+
624
+ def length()
625
+ return @iterations.length
626
+ end
627
+
628
+ def each(&b)
629
+ @iterations.each(&b)
630
+ end
631
+
632
+ def add(timeset)
633
+ #; [!thyms] adds timeset and returns self.
634
+ @iterations << timeset
635
+ self
636
+ end
637
+
638
+ def clear()
639
+ #; [!fxrn6] clears timeset array.
640
+ @iterations.clear()
641
+ self
642
+ end
643
+
644
+ def skipped=(reason)
645
+ @reason = reason
646
+ end
647
+
648
+ def skipped?
649
+ #; [!bvzk9] returns true if reason has set, or returns false.
650
+ return !!@reason
651
+ end
652
+
653
+ def remove_minmax(extra, key=:real)
654
+ #; [!b55zh] removes best and worst timeset and returns them.
655
+ i = 0
656
+ pairs = @iterations.collect {|t| [t, i+=1] }
657
+ pairs = pairs.sort_by {|pair| pair[0].__send__(key) }
658
+ removed = []
659
+ extra.times do
660
+ min_timeset, min_idx = pairs.shift()
661
+ max_timeset, max_idx = pairs.pop()
662
+ min_t = min_timeset.__send__(key)
663
+ max_t = max_timeset.__send__(key)
664
+ removed << [min_t, min_idx, max_t, max_idx]
665
+ end
666
+ remained = pairs.sort_by {|_, i| i }.collect {|t, _| t }
667
+ @iterations = remained
668
+ return removed
669
+ end
670
+
671
+ def calc_average()
672
+ #; [!b91w3] returns average of timeddata.
673
+ user = sys = total = real = 0.0
674
+ @iterations.each do |t|
675
+ user += t.user
676
+ sys += t.sys
677
+ total += t.total
678
+ real += t.real
679
+ end
680
+ n = @iterations.length
681
+ return TimeSet.new(user/n, sys/n, total/n, real/n)
682
+ end
683
+
684
+ end
685
+
686
+
687
+ module Misc
688
+
689
+ module_function
690
+
691
+ def environment_info()
692
+ #; [!w1xfa] returns environment info in key-value list.
693
+ ruby_engine_version = (RUBY_ENGINE_VERSION rescue nil)
694
+ cc_version_msg = RbConfig::CONFIG['CC_VERSION_MESSAGE']
695
+ return [
696
+ ["benchmarker" , "release #{VERSION}"],
697
+ ["ruby engine" , "#{RUBY_ENGINE} (engine version #{ruby_engine_version})"],
698
+ ["ruby version" , "#{RUBY_VERSION} (patch level #{RUBY_PATCHLEVEL})"],
699
+ ["ruby platform" , RUBY_PLATFORM],
700
+ ["ruby path" , RbConfig.ruby],
701
+ ["compiler" , cc_version_msg ? cc_version_msg.split(/\r?\n/)[0] : nil],
702
+ ["os name" , os_name()],
703
+ ["cpu model" , cpu_model()],
704
+ ]
705
+ end
706
+
707
+ def os_name()
708
+ #; [!83vww] returns string representing os name.
709
+ if File.file?("/usr/bin/sw_vers") # macOS
710
+ s = `/usr/bin/sw_vers`
711
+ s =~ /^ProductName:\s+(.*)/; product = $1
712
+ s =~ /^ProductVersion:\s+(.*)/; version = $1
713
+ return "#{product} #{version}"
714
+ end
715
+ if File.file?("/etc/lsb-release") # Linux
716
+ s = File.read("/etc/lsb-release", encoding: 'utf-8')
717
+ if s =~ /^DISTRIB_DESCRIPTION="(.*)"/
718
+ return $1
719
+ end
720
+ end
721
+ if File.file?("/usr/bin/uname") # UNIX
722
+ s = `/usr/bin/uname -srm`
723
+ return s.strip
724
+ end
725
+ if RUBY_PLATFORM =~ /win/ # Windows
726
+ s = `systeminfo` # TODO: not tested yet
727
+ s =~ /^OS Name:\s+(?:Microsft )?(.*)/; product = $1
728
+ s =~ /^OS Version:\s+(.*)/; version = $1 ? $1.split()[0] : nil
729
+ return "#{product} #{version}"
730
+ end
731
+ return nil
732
+ end
733
+
734
+ def cpu_model()
735
+ #; [!6ncgq] returns string representing cpu model.
736
+ if File.exist?("/usr/sbin/sysctl") # macOS
737
+ s = `/usr/sbin/sysctl machdep.cpu.brand_string`
738
+ s =~ /^machdep\.cpu\.brand_string: (.*)/
739
+ return $1
740
+ elsif File.exist?("/proc/cpuinfo") # Linux
741
+ s = `cat /proc/cpuinfo`
742
+ s =~ /^model name\s*: (.*)/
743
+ return $1
744
+ elsif File.exist?("/var/run/dmesg.boot") # FreeBSD
745
+ s = `grep ^CPU: /var/run/dmesg.boot`
746
+ s =~ /^CPU: (.*)/
747
+ return $1
748
+ elsif RUBY_PLATFORM =~ /win/ # Windows
749
+ s = `systeminfo`
750
+ s =~ /^\s+\[01\]: (.*)/ # TODO: not tested yet
751
+ return $1
752
+ else
753
+ return nil
754
+ end
755
+ end
756
+
757
+ def sample_code()
758
+ return <<'END'
759
+ # -*- coding: utf-8 -*-
760
+
761
+ require 'benchmarker' # https://kwatch.github.io/benchmarker/ruby.html
762
+
763
+ nums = (1..10000).to_a
764
+
765
+ title = "calculate sum of integers"
766
+ Benchmarker.scope(title, width: 24, loop: 1000, iter: 5, extra: 1) do
767
+ ## other options -- inverse: true, outfile: "result.json", quiet: true,
768
+ ## sleep: 1, colorize: true, filter: "task=*foo*"
769
+
770
+ ## hooks
771
+ #before_all do end
772
+ #after_all do end
773
+ #before do end # or: before do |task_name, tag| end
774
+ #after do end # or: after do |task_name, tag| end
775
+
776
+ ## tasks
777
+ task nil do # empty-loop task
778
+ # do nothing
779
+ end
780
+
781
+ task "each() & '+='" do
782
+ total = 0
783
+ nums.each {|n| total += n }
784
+ total
785
+ end
786
+
787
+ task "inject()" do
788
+ total = nums.inject(0) {|t, n| t += n }
789
+ total
790
+ end
791
+
792
+ task "while statement" do
793
+ total = 0; i = -1; len = nums.length
794
+ while (i += 1) < len
795
+ total += nums[i]
796
+ end
797
+ total
798
+ end
799
+
800
+ #task "name", tag: "curr", skip: (!condition ? nil : "...reason...") do
801
+ # ... run benchmark code ...
802
+ #end
803
+
804
+ ## validation
805
+ validate do |val| # or: validate do |val, task_name, tag|
806
+ n = nums.last
807
+ expected = n * (n+1) / 2
808
+ assert_eq val, expected
809
+ # or: assert val == expected, "expected #{expected} but got #{val}"
810
+ end
811
+
812
+ end
813
+ END
814
+ end
815
+
816
+ end
817
+
818
+
819
+ module Color
820
+
821
+ module_function
822
+
823
+ def black(s); "\e[0;30m#{s}\e[0m"; end
824
+ def red(s); "\e[0;31m#{s}\e[0m"; end
825
+ def green(s); "\e[0;32m#{s}\e[0m"; end
826
+ def yellow(s); "\e[0;33m#{s}\e[0m"; end
827
+ def blue(s); "\e[0;34m#{s}\e[0m"; end
828
+ def magenta(s); "\e[0;35m#{s}\e[0m"; end
829
+ def cyan(s); "\e[0;36m#{s}\e[0m"; end
830
+ def white(s); "\e[0;37m#{s}\e[0m"; end
831
+
832
+ class << self
833
+ alias real cyan
834
+ alias iter magenta
835
+ end
836
+
837
+ def colorize?
838
+ #; [!fc741] returns true if stdout is a tty, else returns false.
839
+ return $stdout.tty?
840
+ end
841
+
842
+ end
843
+
844
+
845
+ class OptionParser
846
+
847
+ def initialize(opts_noparam, opts_hasparam, opts_mayparam="")
848
+ @opts_noparam = opts_noparam
849
+ @opts_hasparam = opts_hasparam
850
+ @opts_mayparam = opts_mayparam
851
+ end
852
+
853
+ def parse(argv)
854
+ #; [!2gq7g] returns options and keyvals.
855
+ options = {}; keyvals = {}
856
+ while !argv.empty? && argv[0] =~ /^-/
857
+ argstr = argv.shift
858
+ case argstr
859
+ #; [!ulfpu] stops parsing when '--' found.
860
+ when '--'
861
+ break
862
+ #; [!8f085] regards '--long=option' as key-value.
863
+ when /^--/
864
+ argstr =~ /^--(\w[-\w]*)(?:=(.*))?$/ or
865
+ yield "#{argstr}: invalid option."
866
+ key = $1; val = $2
867
+ keyvals[key] = val || true
868
+ #; [!dkq1u] parses short options.
869
+ when /^-/
870
+ i = 1
871
+ while i < argstr.length
872
+ c = argstr[i]
873
+ if @opts_noparam.include?(c)
874
+ options[c] = true
875
+ i += 1
876
+ elsif @opts_hasparam.include?(c)
877
+ #; [!8xqla] error when required argument is not provided.
878
+ options[c] = i+1 < argstr.length ? argstr[(i+1)..-1] : argv.shift() or
879
+ yield "-#{c}: argument required."
880
+ break
881
+ elsif @opts_mayparam.include?(c)
882
+ options[c] = i+1 < argstr.length ? argstr[(i+1)..-1] : true
883
+ break
884
+ #; [!tmx6o] error when option is unknown.
885
+ else
886
+ yield "-#{c}: unknown option."
887
+ i += 1
888
+ end
889
+ end
890
+ else
891
+ raise "** internall error"
892
+ end
893
+ end
894
+ return options, keyvals
895
+ end
896
+
897
+ def self.parse_options(argv=ARGV, &b)
898
+ parser = self.new("hvqcCS", "wnixosF", "I")
899
+ options, keyvals = parser.parse(argv, &b)
900
+ #; [!v19y5] converts option argument into integer if necessary.
901
+ "wnixI".each_char do |c|
902
+ next if !options.key?(c)
903
+ next if options[c] == true
904
+ #; [!frfz2] yields error message when argument of '-n/i/x/I' is not an integer.
905
+ options[c] =~ /\A\d+\z/ or
906
+ yield "-#{c}#{c == 'I' ? '' : ' '}#{options[c]}: integer expected."
907
+ options[c] = options[c].to_i
908
+ end
909
+ #; [!nz15w] convers '-s' option value into number (integer or float).
910
+ "s".each_char do |c|
911
+ next unless options.key?(c)
912
+ case options[c]
913
+ when /\A\d+\z/ ; options[c] = options[c].to_i
914
+ when /\A\d+\.\d+\z/ ; options[c] = options[c].to_f
915
+ else
916
+ #; [!3x1m7] yields error message when argument of '-s' is not a number.
917
+ yield "-#{c} #{options[c]}: number expected."
918
+ end
919
+ end
920
+ #
921
+ if options['F']
922
+ #; [!emavm] yields error message when argumetn of '-F' option is invalid.
923
+ if options['F'] !~ /^(\w+)(=|!=)[^=]/
924
+ yield "-F #{options['F']}: invalid filter (expected operator is '=' or '!=')."
925
+ elsif ! ($1 == 'task' || $1 == 'tag')
926
+ yield "-F #{options['F']}: expected 'task=...' or 'tag=...'."
927
+ end
928
+ end
929
+ return options, keyvals
930
+ end
931
+
932
+ def self.help_message(command=nil)
933
+ #; [!jnm2w] returns help message.
934
+ command ||= File.basename($0)
935
+ return <<"END"
936
+ Usage: #{command} [<options>]
937
+ -h, --help : help message
938
+ -v : print Benchmarker version
939
+ -w <N> : width of task name (default: 30)
940
+ -n <N> : loop N times in each benchmark (default: 1)
941
+ -i <N> : iterates all benchmark tasks N times (default: 1)
942
+ -x <N> : ignore worst N results and best N results (default: 0)
943
+ -I[<N>] : print inverse number (= N/sec) (default: same as '-n')
944
+ -o <file> : output file in JSON format
945
+ -q : quiet a little (suppress output of each iteration)
946
+ -c : enable colorized output
947
+ -C : disable colorized output
948
+ -s <N> : sleep N seconds after each benchmark task
949
+ -S : print sample code
950
+ -F task=<...> : filter benchmark task by name (operator: '=' or '!=')
951
+ -F tag=<...> : filter benchmark task by tag (operator: '=' or '!=')
952
+ --<key>[=<val>]: define global variable `$opt_<key> = "<val>"`
953
+ END
954
+ end
955
+
956
+ end
957
+
958
+
959
+ def self.parse_cmdopts(argv=ARGV)
960
+ #; [!348ip] parses command-line options.
961
+ #; [!snqxo] exits with status code 1 if error in command option.
962
+ options, keyvals = OptionParser.parse_options(argv) do |errmsg|
963
+ $stderr.puts errmsg
964
+ exit 1
965
+ end
966
+ #; [!p3b93] prints help message if '-h' or '--help' option specified.
967
+ if options['h'] || keyvals['help']
968
+ puts OptionParser.help_message()
969
+ exit 0
970
+ end
971
+ #; [!iaryj] prints version number if '-v' option specified.
972
+ if options['v']
973
+ puts VERSION
974
+ exit 0
975
+ end
976
+ #; [!nrxsb] prints sample code if '-S' option specified.
977
+ if options['S']
978
+ puts Misc.sample_code()
979
+ exit 0
980
+ end
981
+ #; [!s7y6x] keeps command-line options in order to overwirte existing options.
982
+ #; [!nexi8] option '-w' specifies task name width.
983
+ #; [!raki9] option '-n' specifies count of loop.
984
+ #; [!mt7lw] option '-i' specifies number of iteration.
985
+ #; [!7f2k3] option '-x' specifies number of best/worst tasks removed.
986
+ #; [!r0439] option '-I' specifies inverse switch.
987
+ #; [!4c73x] option '-o' specifies outout JSON file.
988
+ #; [!02ml5] option '-q' specifies quiet mode.
989
+ #; [!e5hv0] option '-c' specifies colorize enabled.
990
+ #; [!eb5ck] option '-C' specifies colorize disabled.
991
+ #; [!6nxi8] option '-s' specifies sleep time.
992
+ #; [!muica] option '-F' specifies filter.
993
+ OPTIONS[:width] = options['w'] if options['w']
994
+ OPTIONS[:loop] = options['n'] if options['n']
995
+ OPTIONS[:iter] = options['i'] if options['i']
996
+ OPTIONS[:extra] = options['x'] if options['x']
997
+ OPTIONS[:inverse] = options['I'] if options['I']
998
+ OPTIONS[:outfile] = options['o'] if options['o']
999
+ OPTIONS[:quiet] = options['q'] if options['q']
1000
+ OPTIONS[:colorize] = true if options['c']
1001
+ OPTIONS[:colorize] = false if options['C']
1002
+ OPTIONS[:sleep] = options['s'] if options['s']
1003
+ OPTIONS[:filter] = options['F'] if options['F']
1004
+ #; [!3khc4] sets global variables if long option specified.
1005
+ keyvals.each {|k, v| eval "$opt_#{k} = #{v.inspect}" }
1006
+ #
1007
+ return options, keyvals # for testing
1008
+ end
1009
+
1010
+ unless defined?(::BENCHMARKER_IGNORE_CMDOPTS) && ::BENCHMARKER_IGNORE_CMDOPTS
1011
+ self.parse_cmdopts(ARGV)
1012
+ end
1013
+
1014
+
1015
+ end
1016
+
1017
+
1018
+
1019
+ ## for compatibility with 'benchmark.rb' (standard library)
1020
+ module Benchmark
1021
+
1022
+ def self.__new_bm(width, &block) # :nodoc:
1023
+ bm = Benchmarker.new(width: width)
1024
+ scope = Benchmarker::Scope.new(bm)
1025
+ scope.instance_exec(scope, &block)
1026
+ return bm
1027
+ end
1028
+
1029
+ def self.bm(width=nil, &block)
1030
+ #; [!2nf07] defines and runs benchmark.
1031
+ __new_bm(width, &block).run()
1032
+ nil
1033
+ end
1034
+
1035
+ def self.bmbm(width=nil, &block)
1036
+ #; [!ezbb8] defines and runs benchmark twice, reports only 2nd result.
1037
+ __new_bm(width, &block).run(warmup: true)
1038
+ nil
1039
+ end
1040
+
1041
+ end unless defined?(::Benchmark)