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.
@@ -1,3 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "mkmf"
2
4
 
5
+ # For testing in CI (because I don't otherwise have easy access to Mac OS):
6
+ # $CFLAGS << " -D__D_HEAP_DEBUG" if /darwin/ =~ RUBY_PLATFORM
7
+ # $CFLAGS << " -debug inline-debug-info "
8
+ # $CFLAGS << " -g -ginline-points "
9
+ # $CFLAGS << " -fno-omit-frame-pointer "
10
+
11
+ if enable_config("debug")
12
+ CONFIG["warnflags"] << " -Werror -Wpedantic "
13
+ end
14
+
15
+ have_func "rb_gc_mark_movable" # since ruby-2.7
16
+
17
+ check_sizeof("long")
18
+ check_sizeof("unsigned long long")
19
+ check_sizeof("long double")
20
+ have_macro("LDBL_MANT_DIG", "float.h")
21
+
3
22
  create_makefile("d_heap/d_heap")
Binary file
Binary file
Binary file
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English" # $CHILD_STATUS
4
+ require "timeout" # Timeout::Error
5
+
6
+ require "benchmark_driver"
7
+
8
+ # monkey-patch to convert miniscule values to 0.0
9
+ class BenchmarkDriver::Output::Compare
10
+
11
+ # monkey-patch to convert miniscule values to 0.0
12
+ module MinisculeToZero
13
+
14
+ def humanize(value, width = 10)
15
+ value <= 0.0.next_float.next_float ? 0.0 : super(value, width)
16
+ end
17
+
18
+ end
19
+
20
+ prepend MinisculeToZero
21
+
22
+ end
23
+
24
+ # A simple patch to let slow specs error out without
25
+ class BenchmarkDriver::Runner::IpsZeroFail < BenchmarkDriver::Runner::Ips
26
+ METRIC = BenchmarkDriver::Runner::Ips::METRIC
27
+
28
+ # always run at least once
29
+ class Job < BenchmarkDriver::DefaultJob
30
+ attr_accessor :warmup_value, :warmup_duration, :warmup_loop_count
31
+
32
+ def add_warmup_attrs(value, duration, loop_count)
33
+ self.warmup_value = value
34
+ self.warmup_duration = duration
35
+ self.warmup_loop_count = loop_count
36
+ end
37
+
38
+ end
39
+
40
+ # BenchmarkDriver::Runner looks for this class
41
+ JobParser = BenchmarkDriver::DefaultJobParser.for(klass: Job, metrics: [METRIC])
42
+
43
+ # This method is dynamically called by `BenchmarkDriver::JobRunner.run`
44
+ # @param [Array<BenchmarkDriver::Default::Job>] jobs
45
+ def run(jobs)
46
+ jobs = run_all_jobs_warmup(jobs)
47
+ run_all_jobs_benchmarks(jobs)
48
+ end
49
+
50
+ def run_all_jobs_warmup(jobs)
51
+ return jobs if jobs.all?(&:loop_count)
52
+ @output.with_warmup do
53
+ jobs.map! {|job|
54
+ # skip warmup if loop_count is set
55
+ job.loop_count ? job : output_warmup_and_config_job(job)
56
+ }
57
+ end
58
+ end
59
+
60
+ def run_all_jobs_benchmarks(jobs)
61
+ @output.with_benchmark do
62
+ jobs.each do |job|
63
+ @output.with_job(name: job.name) do
64
+ job.runnable_contexts(@contexts).each do |context|
65
+ run_and_report_job(job, context)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ def output_warmup_and_config_job(job)
73
+ @output.with_job(name: job.name) do
74
+ context = job.runnable_contexts(@contexts).first
75
+ value, duration, warmup_loop_count = run_and_report_warmup_job(job, context)
76
+ loop_count = (warmup_loop_count.to_f * @config.run_duration / duration).floor
77
+ Job.new(**job.to_h.merge(loop_count: loop_count))
78
+ .tap {|j| j.add_warmup_attrs(value, duration, warmup_loop_count) }
79
+ end
80
+ end
81
+
82
+ def run_and_report_warmup_job(job, context)
83
+ duration, loop_count = run_warmup(job, context: context)
84
+ value, duration = value_duration(duration: duration, loop_count: loop_count)
85
+ output_with_context(context) do
86
+ @output.report(
87
+ values: {metric => value}, duration: duration, loop_count: loop_count
88
+ )
89
+ end
90
+ [value, duration, loop_count]
91
+ end
92
+
93
+ def run_and_report_job(job, context)
94
+ result, loop_count = run_job_with_repeater(job, context)
95
+ value, duration = result.value
96
+ output_with_context(context) do
97
+ @output.report(
98
+ values: { metric => value },
99
+ all_values: { metric => result.all_values },
100
+ duration: duration,
101
+ loop_count: loop_count,
102
+ )
103
+ end
104
+ end
105
+
106
+ def output_with_context(context, &block)
107
+ @output.with_context(
108
+ name: context.name,
109
+ executable: context.executable,
110
+ gems: context.gems,
111
+ prelude: context.prelude,
112
+ &block
113
+ )
114
+ end
115
+
116
+ def run_job_with_repeater(job, context)
117
+ repeat_params = { config: @config, larger_better: true, rest_on_average: :average }
118
+ if job.loop_count&.positive?
119
+ run_job_with_own_loop_count(job, context, repeat_params)
120
+ else
121
+ run_job_with_warmup_loop_count(job, context, repeat_params)
122
+ end
123
+ end
124
+
125
+ def run_job_with_own_loop_count(job, context, repeat_params)
126
+ loop_count = job.loop_count
127
+ result = BenchmarkDriver::Repeater.with_repeat(**repeat_params) {
128
+ run_benchmark(job, context: context)
129
+ }
130
+ [result, loop_count]
131
+ end
132
+
133
+ def run_job_with_warmup_loop_count(job, context, repeat_params)
134
+ loop_count = job.warmup_loop_count
135
+ repeater_value = [job.warmup_value, job.warmup_duration]
136
+ result = BenchmarkDriver::Repeater::RepeatResult.new(
137
+ value: repeater_value, all_values: [repeater_value]
138
+ )
139
+ [result, loop_count]
140
+ end
141
+
142
+ def run_warmup(job, context:)
143
+ start = Time.now
144
+ super(job, context: context)
145
+ rescue Timeout::Error
146
+ [Time.now - start, 0.0.next_float]
147
+ end
148
+
149
+ def execute(*args, exception: true)
150
+ super
151
+ rescue RuntimeError => ex
152
+ if args.include?("timeout") && $CHILD_STATUS&.exitstatus == 124
153
+ raise Timeout::Error, ex.message
154
+ end
155
+ raise ex
156
+ end
157
+
158
+ end
@@ -1,10 +1,111 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "d_heap/d_heap"
2
4
  require "d_heap/version"
3
5
 
6
+ # A fast _d_-ary heap implementation for ruby, useful in priority queues and graph
7
+ # algorithms.
8
+ #
9
+ # The _d_-ary heap data structure is a generalization of the binary heap, in which
10
+ # the nodes have _d_ children instead of 2. This allows for "decrease priority"
11
+ # operations to be performed more quickly with the tradeoff of slower delete
12
+ # minimum. Additionally, _d_-ary heaps can have better memory cache behavior than
13
+ # binary heaps, allowing them to pop more quickly in practice despite slower
14
+ # worst-case time complexity.
15
+ #
16
+ # Although _d_ can be configured when creating the heap, it's usually best to
17
+ # keep the default value of 4, because d=4 gives the smallest coefficient for
18
+ # <tt>(d + 1) log n / log d</tt> result. As always, use benchmarks for your
19
+ # particular use-case.
20
+ #
21
+ # @example Basic push, peek, and pop
22
+ # # create some example objects to place in our heap
23
+ # Task = Struct.new(:id, :time) do
24
+ # def to_f; time.to_f end
25
+ # end
26
+ # t1 = Task.new(1, Time.now + 5*60)
27
+ # t2 = Task.new(2, Time.now + 50)
28
+ # t3 = Task.new(3, Time.now + 60)
29
+ # t4 = Task.new(4, Time.now + 5)
30
+ #
31
+ # # create the heap
32
+ # require "d_heap"
33
+ # heap = DHeap.new
34
+ #
35
+ # # push with an explicit score (which might be extrinsic to the value)
36
+ # heap.push t1, t1.to_f
37
+ #
38
+ # # the score will be implicitly cast with Float, so any object with #to_f
39
+ # heap.push t2, t2
40
+ #
41
+ # # if the object has an intrinsic score via #to_f, "<<" is the simplest API
42
+ # heap << t3 << t4
43
+ #
44
+ # # pop returns the lowest scored item, and removes it from the heap
45
+ # heap.pop # => #<struct Task id=4, time=2021-01-17 17:02:22.5574 -0500>
46
+ # heap.pop # => #<struct Task id=2, time=2021-01-17 17:03:07.5574 -0500>
47
+ #
48
+ # # peek returns the lowest scored item, without removing it from the heap
49
+ # heap.peek # => #<struct Task id=3, time=2021-01-17 17:03:17.5574 -0500>
50
+ # heap.pop # => #<struct Task id=3, time=2021-01-17 17:03:17.5574 -0500>
51
+ #
52
+ # # pop_lte handles the common "h.pop if h.peek_score < max" pattern
53
+ # heap.pop_lte(Time.now + 65) # => nil
54
+ #
55
+ # # the heap size can be inspected with size and empty?
56
+ # heap.empty? # => false
57
+ # heap.size # => 1
58
+ # heap.pop # => #<struct Task id=1, time=2021-01-17 17:07:17.5574 -0500>
59
+ # heap.empty? # => true
60
+ # heap.size # => 0
61
+ #
62
+ # # popping from an empty heap returns nil
63
+ # heap.pop # => nil
64
+ #
4
65
  class DHeap
66
+ alias deq pop
67
+ alias shift pop
68
+ alias next pop
69
+ alias pop_all_lt pop_all_below
70
+ alias pop_below pop_lt
71
+
72
+ alias enq push
73
+
74
+ alias first peek
75
+
76
+ alias length size
77
+ alias count size
78
+
79
+ # Initialize a _d_-ary min-heap.
80
+ #
81
+ # @param d [Integer] Number of children for each parent node.
82
+ # Higher values generally speed up push but slow down pop.
83
+ # If all pushes are popped, the default is probably best.
84
+ # @param capacity [Integer] initial capacity of the heap.
85
+ def initialize(d: DEFAULT_D, capacity: DEFAULT_CAPA) # rubocop:disable Naming/MethodParameterName
86
+ __init_without_kw__(d, capacity)
87
+ end
5
88
 
6
- def initialize_copy(other)
7
- raise NotImplementedError, "initialize_copy should deep copy array"
89
+ # Consumes the heap by popping each minumum value until it is empty.
90
+ #
91
+ # If you want to iterate over the heap without consuming it, you will need to
92
+ # first call +#dup+
93
+ #
94
+ # @param with_score [Boolean] if scores shoul also be yielded
95
+ #
96
+ # @yieldparam value [Object] each value that would be popped
97
+ # @yieldparam score [Numeric] each value's score, if +with_scores+ is true
98
+ #
99
+ # @return [Enumerator] if no block is given
100
+ # @return [nil] if a block is given
101
+ def each_pop(with_scores: false)
102
+ return to_enum(__method__, with_scores: with_scores) unless block_given?
103
+ if with_scores
104
+ yield(*pop_with_score) until empty?
105
+ else
106
+ yield pop until empty?
107
+ end
108
+ nil
8
109
  end
9
110
 
10
111
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "d_heap"
4
+ require "ostruct"
5
+
6
+ # Different benchmark scenarios and implementations to benchmark
7
+ module DHeap::Benchmarks
8
+
9
+ def self.puts_version_info(type = "Benchmark", io = $stdout)
10
+ io.puts "#{type} run at %s" % [Time.now]
11
+ io.puts "ruby v%s, DHeap v%s" % [RUBY_VERSION, DHeap::VERSION]
12
+ io.puts
13
+ end
14
+
15
+ # rubocop:disable Style/NumericPredicate
16
+
17
+ # moves "rand" outside the benchmarked code, to avoid measuring that too.
18
+ module Randomness
19
+
20
+ def default_randomness_size; 1_000_000 end
21
+
22
+ def fill_random_vals(target_size = default_randomness_size, io: $stdout)
23
+ @dheap_bm_random_vals ||= []
24
+ count = target_size - @dheap_bm_random_vals.length
25
+ return 0 if count <= 0
26
+ millions = (count / 1_000_000.0).round(3)
27
+ io&.puts "~~~~~~ filling @dheap_bm_random_vals with #{millions}M ~~~~~~"
28
+ io&.flush
29
+ count.times do @dheap_bm_random_vals << rand(0..10_000) end
30
+ @dheap_bm_random_len = @dheap_bm_random_vals.length
31
+ @dheap_bm_random_idx = (((@dheap_bm_random_idx || -1) + 1) % @dheap_bm_random_len)
32
+ nil
33
+ end
34
+
35
+ def random_val
36
+ @dheap_bm_random_vals.fetch(
37
+ @dheap_bm_random_idx = ((@dheap_bm_random_idx + 1) % @dheap_bm_random_len)
38
+ )
39
+ end
40
+
41
+ end
42
+
43
+ # different scenarios to be benchmarked or profiled
44
+ module Scenarios
45
+
46
+ def push_n_multiple_queues(count, *queues)
47
+ while 0 < count
48
+ value = @dheap_bm_random_vals.fetch(
49
+ @dheap_bm_random_idx = ((@dheap_bm_random_idx + 1) % @dheap_bm_random_len)
50
+ )
51
+ queues.each do |queue|
52
+ queue << value
53
+ end
54
+ count -= 1
55
+ end
56
+ end
57
+
58
+ def push_n(queue, count)
59
+ while 0 < count
60
+ queue << @dheap_bm_random_vals.fetch(
61
+ @dheap_bm_random_idx = ((@dheap_bm_random_idx + 1) % @dheap_bm_random_len)
62
+ )
63
+ count -= 1
64
+ end
65
+ end
66
+
67
+ def push_n_then_pop_n(queue, count) # rubocop:disable Metrics/MethodLength
68
+ i = 0
69
+ while i < count
70
+ queue << @dheap_bm_random_vals.fetch(
71
+ @dheap_bm_random_idx = ((@dheap_bm_random_idx + 1) % @dheap_bm_random_len)
72
+ )
73
+ i += 1
74
+ end
75
+ while 0 < i
76
+ queue.pop
77
+ i -= 1
78
+ end
79
+ end
80
+
81
+ def repeated_push_pop(queue, count)
82
+ while 0 < count
83
+ queue << @dheap_bm_random_vals.fetch(
84
+ @dheap_bm_random_idx = ((@dheap_bm_random_idx + 1) % @dheap_bm_random_len)
85
+ )
86
+ queue.pop
87
+ count -= 1
88
+ end
89
+ end
90
+
91
+ end
92
+
93
+ include Randomness
94
+ include Scenarios
95
+
96
+ def initq(klass, count = 0, clear: false)
97
+ queue = klass.new
98
+ while 0 < count
99
+ queue << @dheap_bm_random_vals.fetch(
100
+ @dheap_bm_random_idx = ((@dheap_bm_random_idx + 1) % @dheap_bm_random_len)
101
+ )
102
+ count -= 1
103
+ end
104
+ queue.clear if clear
105
+ queue
106
+ end
107
+
108
+ # rubocop:enable Style/NumericPredicate
109
+
110
+ require "d_heap/benchmarks/implementations"
111
+
112
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "d_heap/benchmarks"
4
+
5
+ require "benchmark_driver"
6
+ require "shellwords"
7
+ require "English"
8
+
9
+ module DHeap::Benchmarks
10
+ # Benchmarks different implementations with different sizes
11
+ class Benchmarker
12
+ include Randomness
13
+ include Scenarios
14
+
15
+ N_COUNTS = [
16
+ 5, # 1 + 4
17
+ 21, # 1 + 4 + 16
18
+ 85, # 1 + 4 + 16 + 64
19
+ 341, # 1 + 4 + 16 + 64 + 256
20
+ 1365, # 1 + 4 + 16 + 64 + 256 + 1024
21
+ 5461, # 1 + 4 + 16 + 64 + 256 + 1024 + 4096
22
+ 21_845, # 1 + 4 + 16 + 64 + 256 + 1024 + 4096 + 16384
23
+ 87_381, # 1 + 4 + 16 + 64 + 256 + 1024 + 4096 + 16384 + 65536
24
+ ].freeze
25
+
26
+ attr_reader :time
27
+ attr_reader :iterations_for_push_pop
28
+ attr_reader :io
29
+
30
+ def initialize(
31
+ time: Integer(ENV.fetch("BENCHMARK_TIME", 10)),
32
+ iterations_for_push_pop: 10_000,
33
+ io: $stdout
34
+ )
35
+ @time = time
36
+ @iterations_for_push_pop = Integer(iterations_for_push_pop)
37
+ @io = io
38
+ end
39
+
40
+ def call(queue_size: ENV.fetch("BENCHMARK_QUEUE_SIZE", :unset))
41
+ DHeap::Benchmarks.puts_version_info("Benchmarking")
42
+ sizes = (queue_size == :unset) ? N_COUNTS : [Integer(queue_size)]
43
+ sizes.each do |size|
44
+ benchmark_size(size)
45
+ end
46
+ end
47
+
48
+ def benchmark_size(size)
49
+ sep "#", "Benchmarks with N=#{size} (t=#{time}sec/benchmark)", big: true
50
+ io.puts
51
+ benchmark_push_n size
52
+ benchmark_push_n_then_pop_n size
53
+ benchmark_repeated_push_pop size
54
+ end
55
+
56
+ def benchmark_push_n(queue_size)
57
+ benchmarking("push N", "push_n", queue_size)
58
+ end
59
+
60
+ def benchmark_push_n_then_pop_n(queue_size)
61
+ benchmarking("push N then pop N", "push_n_pop_n", queue_size)
62
+ end
63
+
64
+ def benchmark_repeated_push_pop(queue_size)
65
+ benchmarking(
66
+ "Push/pop with pre-filled queue (size=N)", "push_pop", queue_size
67
+ )
68
+ end
69
+
70
+ private
71
+
72
+ # TODO: move somewhere else...
73
+ def skip_profiling?(queue_size, impl)
74
+ impl.klass == DHeap::Benchmarks::PushAndResort && 10_000 < queue_size
75
+ end
76
+
77
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
78
+
79
+ def benchmarking(name, file, size)
80
+ Bundler.with_unbundled_env do
81
+ sep "==", "#{name} (N=#{size})"
82
+ cmd = %W[
83
+ bin/benchmark-driver
84
+ --bundler
85
+ --run-duration 6
86
+ --timeout 15
87
+ --runner ips_zero_fail
88
+ benchmarks/#{file}.yml
89
+ ]
90
+ if file == "push_n"
91
+ cmd << "--filter" << /dheap|\bstl\b|\bbsearch\b|\brb_heap\b/.to_s
92
+ end
93
+ env = ENV.to_h.merge(
94
+ "BENCH_N" => size.to_s,
95
+ "RUBYLIB" => File.expand_path("../..", __dir__),
96
+ )
97
+ system(env, *cmd)
98
+ end
99
+ end
100
+
101
+ def sep(sep, msg = "", width: 80, big: false)
102
+ txt = String.new
103
+ txt += "#{sep * (width / sep.length)}\n" if big
104
+ txt += sep
105
+ txt += " #{msg}" if msg && !msg.empty?
106
+ txt += " " unless big
107
+ txt += sep * ((width - txt.length) / sep.length) unless big
108
+ txt += "\n"
109
+ txt += "#{sep * (width / sep.length)}\n" if big
110
+ io.print txt
111
+ end
112
+
113
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
114
+
115
+ end
116
+ end