d_heap 0.2.1 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fc"
4
+
5
+ module DHeap::Benchmarks
6
+
7
+ # base class for example priority queues
8
+ class ExamplePriorityQueue
9
+ attr_reader :a
10
+
11
+ # quick initialization by simply sorting the array once.
12
+ def initialize(count = nil, &block)
13
+ @a = []
14
+ return unless count
15
+ count.times {|i| @a << block.call(i) }
16
+ @a.sort!
17
+ end
18
+
19
+ def clear
20
+ @a.clear
21
+ end
22
+
23
+ def empty?
24
+ @a.empty?
25
+ end
26
+
27
+ if ENV["LOG_LEVEL"] == "debug"
28
+ def dbg(msg)
29
+ puts "%20s: %p, %p" % [msg, @a.first, (@a[1..-1] || []).each_slice(2).to_a]
30
+ end
31
+ else
32
+ def dbg(msg) nil end
33
+ end
34
+
35
+ end
36
+
37
+ # The most naive approach--completely unsorted!--is ironically not the worst.
38
+ class FindMin < ExamplePriorityQueue
39
+
40
+ # O(1)
41
+ def <<(score)
42
+ raise ArgumentError unless score
43
+ @a.push score
44
+ end
45
+
46
+ # O(n)
47
+ def pop
48
+ return unless (score = @a.min)
49
+ index = @a.rindex(score)
50
+ @a.delete_at(index)
51
+ score
52
+ end
53
+
54
+ end
55
+
56
+ # Re-sorting after each insert: this both naive and performs the worst.
57
+ class Sorting < ExamplePriorityQueue
58
+
59
+ # O(n log n)
60
+ def <<(score)
61
+ raise ArgumentError unless score
62
+ @a.push score
63
+ @a.sort!
64
+ end
65
+
66
+ # O(1)
67
+ def pop
68
+ @a.shift
69
+ end
70
+
71
+ end
72
+
73
+ # A very simple example priority queue that is implemented with a sorted array.
74
+ #
75
+ # It uses Array#bsearch + Array#insert to push new values, and Array#pop to pop
76
+ # the min value.
77
+ class BSearch < ExamplePriorityQueue
78
+
79
+ # Array#bsearch_index is O(log n)
80
+ # Array#insert is O(n)
81
+ #
82
+ # So this should be O(n).
83
+ #
84
+ # In practice though, memcpy has a *very* small constant factor.
85
+ # And bsearch_index uses *exactly* (log n / log 2) comparisons.
86
+ def <<(score)
87
+ raise ArgumentError unless score
88
+ index = @a.bsearch_index {|other| score > other } || @a.length
89
+ @a.insert(index, score)
90
+ end
91
+
92
+ # Array#pop is O(1). It updates length without changing capacity or contents.
93
+ #
94
+ # No comparisons are necessary.
95
+ #
96
+ # shift is usually also O(1) and could be used if it were sorted normally.
97
+ def pop
98
+ @a.pop
99
+ end
100
+
101
+ end
102
+
103
+ # a very simple pure ruby binary heap
104
+ class RbHeap < ExamplePriorityQueue
105
+
106
+ def <<(value)
107
+ raise ArgumentError unless value
108
+ @a.push(value)
109
+ sift_up(@a.size - 1, value)
110
+ end
111
+
112
+ def pop
113
+ return if @a.empty?
114
+ popped = @a.first
115
+ value = @a.pop
116
+ last_index = @a.size - 1
117
+ return popped unless 0 <= last_index
118
+
119
+ sift_down(0, last_index, value)
120
+ popped
121
+ end
122
+
123
+ private
124
+
125
+ def sift_up(index, value = @a[index])
126
+ while 0 < index # rubocop:disable Style/NumericPredicate
127
+ parent_index = (index - 1) / 2
128
+ parent_value = @a[parent_index]
129
+ break if parent_value <= value
130
+ @a[index] = parent_value
131
+ index = parent_index
132
+ end
133
+ @a[index] = value
134
+ # check_heap!(index)
135
+ end
136
+
137
+ def sift_down(index, last_index = @a.size - 1, value = @a[index])
138
+ last_parent = (last_index - 1) / 2
139
+ while index <= last_parent
140
+ child_index, child_value = select_min_child(index, last_index)
141
+ break if value <= child_value
142
+ @a[index] = child_value
143
+ index = child_index
144
+ child_index = index * 2 + 1
145
+ end
146
+ @a[index] = value
147
+ end
148
+
149
+ def select_min_child(index, last_index = @a.size - 1)
150
+ child_index = index * 2 + 1
151
+ if child_index < last_index && a[child_index + 1] < @a[child_index]
152
+ child_index += 1
153
+ end
154
+ [child_index, @a[child_index]]
155
+ end
156
+
157
+ def check_heap!(idx)
158
+ check_heap_up!(idx)
159
+ check_heap_dn!(idx)
160
+ end
161
+
162
+ # compares index to its parent
163
+ def check_heap_at!(idx)
164
+ value = @a[idx]
165
+ unless idx <= 0
166
+ pidx = (idx - 1) / 2
167
+ pval = @a[pidx]
168
+ raise "@a[#{idx}] == #{value}, #{pval} > #{value}" if pval > value
169
+ end
170
+ value
171
+ end
172
+
173
+ def check_heap_up!(idx)
174
+ return if idx <= 0
175
+ pidx = (idx - 1) / 2
176
+ check_heap_at!(pidx)
177
+ check_heap_up!(pidx)
178
+ end
179
+
180
+ def check_heap_dn!(idx)
181
+ return unless @a.size <= idx
182
+ check_heap_at!(idx)
183
+ check_heap_down!(idx * 2 + 1)
184
+ check_heap_down!(idx * 2 + 2)
185
+ end
186
+
187
+ end
188
+
189
+ # minor adjustments to the "priority_queue_cxx" gem, to match the API
190
+ class CppSTL
191
+
192
+ def initialize
193
+ clear
194
+ end
195
+
196
+ def <<(value); @q.push(value, value) end
197
+
198
+ def clear
199
+ @q = FastContainers::PriorityQueue.new(:min)
200
+ end
201
+
202
+ def empty?
203
+ @q.empty?
204
+ end
205
+
206
+ def pop
207
+ @q.pop
208
+ rescue RuntimeError
209
+ nil
210
+ end
211
+
212
+ end
213
+
214
+ # Different duck-typed priority queue implemenations
215
+ IMPLEMENTATIONS = [
216
+ OpenStruct.new(name: " push and resort", klass: Sorting).freeze,
217
+ OpenStruct.new(name: " find min + del", klass: FindMin).freeze,
218
+ OpenStruct.new(name: "bsearch + insert", klass: BSearch).freeze,
219
+ OpenStruct.new(name: "ruby binary heap", klass: RbHeap).freeze,
220
+ OpenStruct.new(name: "C++STL PriorityQ", klass: CppSTL).freeze,
221
+ OpenStruct.new(name: "quaternary DHeap", klass: DHeap).freeze,
222
+ ].freeze
223
+
224
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "d_heap/benchmarks"
4
+
5
+ require "ruby-prof"
6
+
7
+ module DHeap::Benchmarks
8
+ # Profiles different implementations with different sizes
9
+ class Profiler
10
+ include Randomness
11
+ include Scenarios
12
+
13
+ N_COUNTS = [
14
+ 5, # 1 + 4
15
+ 1365, # 1 + 4 + 16 + 64 + 256 + 1024
16
+ 87_381, # 1 + 4 + 16 + 64 + 256 + 1024 + 4096 + 16384 + 65536
17
+ ].freeze
18
+
19
+ def call(
20
+ queue_size: ENV.fetch("PROFILE_QUEUE_SIZE", :unset),
21
+ iterations: ENV.fetch("PROFILE_ITERATIONS", 1_000_000)
22
+ )
23
+ DHeap::Benchmarks.puts_version_info("Profiling")
24
+ fill_random_vals
25
+ sizes = queue_size == :unset ? N_COUNTS : [Integer(queue_size)]
26
+ sizes.each do |size|
27
+ profile_all(size, iterations)
28
+ end
29
+ end
30
+
31
+ def profile_all(queue_size, iterations, io: $stdout)
32
+ io.puts <<~TEXT
33
+ ########################################################################
34
+ # Profile w/ N=#{queue_size} (i=#{iterations})
35
+ # (n.b. RubyProf & tracepoint can change relative performance.
36
+ # A sampling profiler can provide more accurate relative metrics.
37
+ ########################################################################
38
+
39
+ TEXT
40
+ DHeap::Benchmarks::IMPLEMENTATIONS.each do |impl|
41
+ profile_one(impl, queue_size, iterations, io: io)
42
+ end
43
+ end
44
+
45
+ # TODO: move somewhere else...
46
+ def skip_profiling?(queue_size, impl)
47
+ impl.klass == DHeap::Benchmarks::Sorting && 10_000 < queue_size
48
+ end
49
+
50
+ def profile_one(impl, queue_size, iterations, io: $stdout)
51
+ return if skip_profiling?(queue_size, impl)
52
+ io.puts "Filling #{impl.name} ---------------------------"
53
+ queue = impl.klass.new
54
+ push_n(queue, queue_size)
55
+ io.puts "Profiling #{impl.name} ---------------------------"
56
+ profiling do
57
+ repeated_push_pop(queue, iterations)
58
+ end
59
+ end
60
+
61
+ def profiling(io: $stdout, &block)
62
+ # do the thing
63
+ result = RubyProf.profile(&block)
64
+ # report_the_thing
65
+ printer = RubyProf::FlatPrinter.new(result)
66
+ printer.print($stdout, min_percent: 1.0)
67
+ io.puts
68
+ end
69
+
70
+ end
71
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "d_heap/benchmarks"
4
+
5
+ module DHeap::Benchmarks
6
+
7
+ # Profiles different implementations with different sizes
8
+ module RSpecMatchers # rubocop:disable Metrics/ModuleLength
9
+ extend RSpec::Matchers::DSL
10
+
11
+ # Assert ips (iterations per second):
12
+ #
13
+ # expect { ... }.to perform_at_least(1_000_000).ips
14
+ # .running_at_least(10).times # optional, defaults to 1
15
+ # .running_at_least(10).seconds # optional, defaults to 1s
16
+ # .running_at_most(10_000_000).times # optional, defaults to nil
17
+ # .running_at_most(2).seconds # optional, defaults to 2s
18
+ # .warmup_at_most(1000).times # optional, defaults to 1k
19
+ # .warmup_at_most(0.100).seconds # optional, defaults to 0.1s
20
+ # .iterations_per_round # optional, defaults to 1
21
+ # .and_at_least(1.1).times.faster_than { ... } # can also compare
22
+ #
23
+ # Assert comparison (and optionally runtime or ips):
24
+ #
25
+ # expect { ... }.to perform_at_least(2.5).times_faster_than { ... }
26
+ # .running_at_least(10).times # optional, defaults to 1
27
+ # .running_at_least(10).seconds # optional, defaults to 1s
28
+ # .running_at_most(10_000_000).times # optional, defaults to nil
29
+ # .running_at_most(2).seconds # optional, defaults to 2s
30
+ # .warmup_at_most(1000).times # optional, defaults to 1k
31
+ # .warmup_at_most(0.100).seconds # optional, defaults to 0.1s
32
+ # .iterations_per_call # optional, defaults to 1
33
+ # .and_at_least(100).ips { ... } # can also assert ips
34
+ #
35
+ # n.b: Given a known constant number of iterations, run time and ips are both
36
+ # measuring the same underlying metric.
37
+ #
38
+ # rubocop:disable Metrics/BlockLength, Layout/SpaceAroundOperators
39
+ matcher :perform_at_least do |expected|
40
+ supports_block_expectations
41
+
42
+ %i[
43
+ is_at_least
44
+ running_at_most
45
+ running_at_least
46
+ warmup_at_most
47
+ ].each do |type|
48
+ chain type do |number|
49
+ reason, value = ___number_reason_and_value___
50
+ if reason || value
51
+ raise "Need to handle unit-less number first: %s(%p)" % [reason, value]
52
+ end
53
+ @number_for = type
54
+ @number_val = number
55
+ end
56
+ end
57
+
58
+ alias_method :and_at_least, :is_at_least
59
+
60
+ %i[
61
+ times
62
+ seconds
63
+ milliseconds
64
+ ].each do |unit|
65
+ chain unit do
66
+ reason, value = ___number_reason_and_value___
67
+ raise "No number was specified" unless reason && value
68
+ apply_number_to_reason(reason, value, unit)
69
+ @number_for = @number_val = nil
70
+ end
71
+ end
72
+
73
+ # TODO: let IPS set time to run instead of iterations to run
74
+ chain :ips do
75
+ reason, value = ___number_reason_and_value___
76
+ raise "'ips' unit is only for assertions" unless reason == :is_at_least
77
+ raise "Already asserting %s ips" % [@expect_ips] if @expect_ips
78
+ raise "'ips' assertion has already been made" if @expect_ips
79
+ raise "Unknown assertion count" unless value
80
+ @expect_ips = Integer(value)
81
+ @number_for = @number_val = nil
82
+ end
83
+
84
+ # need to use method because "chain" can't take a block
85
+ def times_faster_than(&other)
86
+ reason, value = ___number_reason_and_value___
87
+ raise "'times_faster_than' is only for assertions" unless reason == :is_at_least
88
+ raise "Already asserting %sx comparison" % [@expect_cmp] if @expect_cmp
89
+ raise ArgumentError, "must provide a proc" unless other
90
+ @expect_cmp = Float(value)
91
+ @cmp_proc = other
92
+ @number_for = @number_val = nil
93
+ self
94
+ end
95
+
96
+ chain :loudly do @volume = :loud end
97
+ chain :quietly do @volume = :quiet end
98
+ chain :volume do |volume|
99
+ raise "Invalid volume" unless %i[loud quiet].include?(volume)
100
+ @volume = volume
101
+ end
102
+
103
+ chain :iterations_per_round do |iterations|
104
+ if @iterations_per_round
105
+ raise "Already set iterations per round (%p)" [@iterations_per_round]
106
+ end
107
+ @iterations_per_round = Integer(iterations)
108
+ end
109
+
110
+ match do |actual|
111
+ require "benchmark"
112
+ raise "Need to expect a proc or block" unless actual.respond_to?(:to_proc)
113
+ raise "Need a performance assertion" unless assertion?
114
+ @actual_proc = actual
115
+ prepare_for_measurement
116
+ if @max_iter && (@max_iter % @iterations_per_round) != 0
117
+ raise "Iterations per round (%p) must divide evenly by max iterations (%p)" % [
118
+ @iterations_per_round, @max_iter,
119
+ ]
120
+ end
121
+ run_measurements
122
+ cmp_okay? && ips_okay?
123
+ end
124
+
125
+ description do
126
+ [
127
+ @expect_cmp && cmp_okay_msg,
128
+ @expect_ips && ips_okay_msg,
129
+ ].join(", and ")
130
+ end
131
+
132
+ failure_message do
133
+ [
134
+ cmp_okay? ? nil : "expected to #{cmp_okay_msg} but #{cmp_fail_msg}", # =>
135
+ ips_okay? ? nil : "expected to #{ips_okay_msg} but #{ips_fail_msg}",
136
+ ].compact.join(", and ")
137
+ end
138
+
139
+ private
140
+
141
+ chain :__convert_expected_to_ivars__ do
142
+ @number_val ||= expected
143
+ @number_for ||= :is_at_least if @number_val
144
+ expected = nil
145
+ end
146
+ private :__convert_expected_to_ivars__
147
+
148
+ def ___number_reason_and_value___
149
+ __convert_expected_to_ivars__
150
+ [@number_for, @number_val]
151
+ end
152
+
153
+ def apply_number_to_reason(reason, value, unit)
154
+ normalized_value, normalized_unit = normalize_unit(unit)
155
+ case reason
156
+ when :running_at_most; apply_max_run normalized_value, normalized_unit
157
+ when :running_at_least; apply_min_run normalized_value, normalized_unit
158
+ when :warmup_at_most; apply_warmup normalized_value, normalized_unit
159
+ else raise "%s is incompatible with %s(%p)" % [unit, reason, value]
160
+ end
161
+ end
162
+
163
+ def normalize_unit(unit)
164
+ case unit
165
+ when :seconds; [Float(@number_val), :seconds]
166
+ when :milliseconds; [Float(@number_val) / 1000.0, :seconds]
167
+ when :times; [Integer(@number_val), :times]
168
+ else raise "Invalid unit %s for %s(%p)" % [unit, reason, value]
169
+ end
170
+ end
171
+
172
+ def apply_min_run(value, unit)
173
+ case unit
174
+ when :seconds; @min_time = value
175
+ when :times; @min_iter = value
176
+ end
177
+ end
178
+
179
+ def apply_max_run(value, unit)
180
+ case unit
181
+ when :seconds; @max_time = value
182
+ when :times; @max_iter = value
183
+ end
184
+ end
185
+
186
+ def apply_warmup(value, unit)
187
+ case unit
188
+ when :seconds; @warmup_time = value
189
+ when :times; @warmup_iter = value
190
+ end
191
+ end
192
+
193
+ def prepare_for_measurement
194
+ @volume ||= ENV.fetch("RSPEC_BENCHMARK_VOLUME", :quiet).to_sym
195
+ @max_time ||= 2
196
+ @min_time ||= 1
197
+ @min_iter ||= 1
198
+ @warmup_time ||= 0.100
199
+ @warmup_iter ||= 1000
200
+ @iterations_per_round ||= 1
201
+ nil
202
+ end
203
+
204
+ def run_measurements
205
+ puts header if loud?
206
+ warmup
207
+ take_measurements
208
+ end
209
+
210
+ def header
211
+ max_rounds = @max_iter && @max_iter / @iterations_per_round
212
+ [
213
+ "Warmup time %s, or iterations: %s" % [@min_iter, @max_iter],
214
+ "Benchmark time (%s..%s) or iterations (%s..%s), max rounds: %p" % [
215
+ @min_time, @max_time, @min_iter, @max_iter, max_rounds,
216
+ ],
217
+ "%-10s %s" % ["", Benchmark::CAPTION],
218
+ ].join("\n")
219
+ end
220
+
221
+ def warmup
222
+ return unless 0 < @warmup_time && 0 < @warmup_iter # rubocop:disable Style/NumericPredicate
223
+ args = [@warmup_iter, 0, @warmup_time, 1, @warmup_iter]
224
+ measure("warmup", *args, &@actual_proc)
225
+ measure("warmup cmp", *args, &@cmp_proc) if @cmp_proc
226
+ end
227
+
228
+ def take_measurements
229
+ args = [@iterations_per_round, @min_time, @max_time, @min_iter, @max_iter]
230
+ @actual_tms = measure("actual", *args, &@actual_proc)
231
+ @cmp_tms = measure("cmp", *args, &@cmp_proc) if @cmp_proc
232
+ return unless @cmp_proc
233
+ # how many times faster?
234
+ @actual_cmp = @actual_tms.ips_real / @cmp_tms.ips_real
235
+ puts "Ran %0.3fx as fast as comparison" % [@actual_cmp] if loud?
236
+ end
237
+
238
+ def loud?; @volume == :loud end
239
+
240
+ def assertion?; !!(@expect_cmp || @expect_ips) end
241
+
242
+ def cmp_okay?; !@expect_cmp || @expect_cmp < @actual_cmp end
243
+ def ips_okay?; !@expect_tms || @expect_tms.ips < @actual_tms.ips end
244
+
245
+ def measure(name, ipr, *args)
246
+ measurements = TmsMeasurements.new(name, ipr, *args)
247
+ measurements.max_rounds.times do
248
+ # GC.start(full_mark: true, immediate_sweep: true)
249
+ # GC.compact
250
+ measurements << Benchmark.measure do
251
+ yield ipr
252
+ end
253
+ # p measurements.real
254
+ break if measurements.max_time < measurements.real
255
+ end
256
+ log_measurement(name, measurements)
257
+ measurements
258
+ end
259
+
260
+ # rubocop:disable Metrics/AbcSize
261
+ def units_str(num)
262
+ if num >= 10**12; "%7.3fT" % [num.to_f / 10**12]
263
+ elsif num >= 10** 9; "%7.3fB" % [num.to_f / 10** 9]
264
+ elsif num >= 10** 6; "%7.3fM" % [num.to_f / 10** 6]
265
+ elsif num >= 10** 3; "%7.3fk" % [num.to_f / 10** 3]
266
+ else "%7.3f" % [num.to_f]
267
+ end
268
+ end
269
+ # rubocop:enable Metrics/AbcSize
270
+
271
+ def log_measurement(name, measurements)
272
+ return unless loud?
273
+ puts "%-10s %s => %s ips (%d rounds)" % [
274
+ name,
275
+ measurements.tms.to_s.rstrip,
276
+ units_str(measurements.ips_real),
277
+ measurements.size,
278
+ ]
279
+ end
280
+
281
+ def cmp_okay_msg; "run %0.2fx faster" % [@expect_cmp] end
282
+ def cmp_fail_msg; "was only %0.2fx as fast" % [@actual_cmp] end
283
+ def ips_okay_msg; "run with %s ips" % [units_str(@expect_ips)] end
284
+ def ips_fail_msg; "was only %s ips" % [units_str(@actual_ips)] end
285
+
286
+ end
287
+ # rubocop:enable Metrics/BlockLength, Layout/SpaceAroundOperators
288
+
289
+ alias_matcher :perform_with, :perform
290
+
291
+ end
292
+
293
+ # Replicates a subset of the functionality in benchmark-ips
294
+ #
295
+ # TODO: merge this with benchmark-ips
296
+ # TODO: implement (or remove) min_time, min_iter
297
+ class TmsMeasurements
298
+ attr_reader :iterations_per_entry
299
+ attr_reader :iterations
300
+
301
+ attr_reader :min_time
302
+ attr_reader :max_time
303
+
304
+ attr_reader :min_iter
305
+ attr_reader :max_iter
306
+
307
+ def initialize(name, ipe, min_time, max_time, min_iter, max_iter) # rubocop:disable Metrics/ParameterLists
308
+ @name = name
309
+ @iterations_per_entry = Integer(ipe)
310
+ @min_time = Float(min_time)
311
+ @max_time = Float(max_time)
312
+ @min_iter = Integer(min_iter)
313
+ @max_iter = Integer(max_iter)
314
+ @entries = []
315
+ @sum = Benchmark::Tms.new
316
+ @iterations = 0
317
+ end
318
+
319
+ def size; entries.size end
320
+
321
+ def <<(tms)
322
+ raise TypeError, "not a #{Benchmark::Tms}" unless tms.is_a?(Benchmark::Tms)
323
+ raise IndexError, "full" if @max_iter <= size
324
+ @sum += tms
325
+ @iterations += @iterations_per_entry
326
+ @entries << tms
327
+ self
328
+ end
329
+
330
+ def sum; @sum.dup end
331
+ alias tms sum
332
+
333
+ def entries; @entries.dup end
334
+
335
+ def cstime; @sum.cstime end
336
+ def cutime; @sum.cutime end
337
+ def real; @sum.real end
338
+ def stime; @sum.stime end
339
+ def total; @sum.total end
340
+ def utime; @sum.utime end
341
+
342
+ def ips_real; @iterations / real end
343
+ def ips_total; @iterations / total end
344
+ def ips_utime; @iterations / utime end
345
+
346
+ def max_rounds
347
+ @max_iter && @max_iter / @iterations_per_entry
348
+ end
349
+
350
+ end
351
+
352
+ end