benchmarker 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)