memo_wise 1.0.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -2,17 +2,21 @@
2
2
 
3
3
  require "benchmark/ips"
4
4
 
5
+ require "tempfile"
5
6
  require "memo_wise"
6
7
 
7
8
  # Some gems do not yet work in Ruby 3 so we only require them if they're loaded
8
9
  # in the Gemfile.
9
- %w[memery memoist memoized memoizer].
10
+ %w[memery memoist memoized memoizer ddmemoize dry-core].
10
11
  each { |gem| require gem if Gem.loaded_specs.key?(gem) }
11
12
 
12
13
  # The VERSION constant does not get loaded above for these gems.
13
14
  %w[memoized memoizer].
14
15
  each { |gem| require "#{gem}/version" if Gem.loaded_specs.key?(gem) }
15
16
 
17
+ # The Memoizable module from dry-core needs to be required manually
18
+ require "dry/core/memoizable" if Gem.loaded_specs.key?("dry-core")
19
+
16
20
  class BenchmarkSuiteWithoutGC
17
21
  def warming(*)
18
22
  run_gc
@@ -36,7 +40,7 @@ class BenchmarkSuiteWithoutGC
36
40
  end
37
41
  suite = BenchmarkSuiteWithoutGC.new
38
42
 
39
- BenchmarkGem = Struct.new(:klass, :inheritance_method, :memoization_method) do
43
+ BenchmarkGem = Struct.new(:klass, :activation_code, :memoization_method) do
40
44
  def benchmark_name
41
45
  "#{klass} (#{klass::VERSION})"
42
46
  end
@@ -47,204 +51,171 @@ end
47
51
  # NOTE: Some gems do not yet work in Ruby 3 so we only test with them if they've
48
52
  # been `require`d.
49
53
  BENCHMARK_GEMS = [
50
- BenchmarkGem.new(MemoWise, :prepend, :memo_wise),
51
- (BenchmarkGem.new(Memery, :include, :memoize) if defined?(Memery)),
52
- (BenchmarkGem.new(Memoist, :extend, :memoize) if defined?(Memoist)),
53
- (BenchmarkGem.new(Memoized, :include, :memoize) if defined?(Memoized)),
54
- (BenchmarkGem.new(Memoizer, :include, :memoize) if defined?(Memoizer))
54
+ BenchmarkGem.new(MemoWise, "prepend MemoWise", :memo_wise),
55
+ (BenchmarkGem.new(DDMemoize, "DDMemoize.activate(self)", :memoize) if defined?(DDMemoize)),
56
+ (BenchmarkGem.new(Dry::Core, "include Dry::Core::Memoizable", :memoize) if defined?(Dry::Core)),
57
+ (BenchmarkGem.new(Memery, "include Memery", :memoize) if defined?(Memery)),
58
+ (BenchmarkGem.new(Memoist, "extend Memoist", :memoize) if defined?(Memoist)),
59
+ (BenchmarkGem.new(Memoized, "include Memoized", :memoize) if defined?(Memoized)),
60
+ (BenchmarkGem.new(Memoizer, "include Memoizer", :memoize) if defined?(Memoizer))
55
61
  ].compact.shuffle
56
62
 
57
63
  # Use metaprogramming to ensure that each class is created in exactly the
58
64
  # the same way.
59
65
  BENCHMARK_GEMS.each do |benchmark_gem|
60
- # rubocop:disable Security/Eval
61
- # rubocop:disable Style/DocumentDynamicEvalDefinition
62
- eval <<-CLASS, binding, __FILE__, __LINE__ + 1
66
+ eval <<~HEREDOC, binding, __FILE__, __LINE__ + 1 # rubocop:disable Security/Eval
67
+ # For these methods, we alternately return truthy and falsey values in
68
+ # order to benchmark memoization when the result of a method is falsey.
69
+ #
70
+ # We do this by checking if the first argument to a method is even.
63
71
  class #{benchmark_gem.klass}Example
64
- #{benchmark_gem.inheritance_method} #{benchmark_gem.klass}
72
+ #{benchmark_gem.activation_code}
65
73
 
66
74
  def no_args
67
75
  100
68
76
  end
69
77
  #{benchmark_gem.memoization_method} :no_args
70
78
 
79
+ # For the no_args case, we can't depend on arguments to alternate between
80
+ # returning truthy and falsey values, so instead make two separate
81
+ # no_args methods
82
+ def no_args_falsey
83
+ nil
84
+ end
85
+ #{benchmark_gem.memoization_method} :no_args_falsey
86
+
71
87
  def one_positional_arg(a)
72
- 100
88
+ 100 if a.positive?
73
89
  end
74
90
  #{benchmark_gem.memoization_method} :one_positional_arg
75
91
 
76
92
  def positional_args(a, b)
77
- 100
93
+ 100 if a.positive?
78
94
  end
79
95
  #{benchmark_gem.memoization_method} :positional_args
80
96
 
81
97
  def one_keyword_arg(a:)
82
- 100
98
+ 100 if a.positive?
83
99
  end
84
100
  #{benchmark_gem.memoization_method} :one_keyword_arg
85
101
 
86
102
  def keyword_args(a:, b:)
87
- 100
103
+ 100 if a.positive?
88
104
  end
89
105
  #{benchmark_gem.memoization_method} :keyword_args
90
106
 
91
107
  def positional_and_keyword_args(a, b:)
92
- 100
108
+ 100 if a.positive?
93
109
  end
94
110
  #{benchmark_gem.memoization_method} :positional_and_keyword_args
95
111
 
96
112
  def positional_and_splat_args(a, *args)
97
- 100
113
+ 100 if a.positive?
98
114
  end
99
115
  #{benchmark_gem.memoization_method} :positional_and_splat_args
100
116
 
101
117
  def keyword_and_double_splat_args(a:, **kwargs)
102
- 100
118
+ 100 if a.positive?
103
119
  end
104
120
  #{benchmark_gem.memoization_method} :keyword_and_double_splat_args
105
121
 
106
122
  def positional_splat_keyword_and_double_splat_args(a, *args, b:, **kwargs)
107
- 100
123
+ 100 if a.positive?
108
124
  end
109
125
  #{benchmark_gem.memoization_method} :positional_splat_keyword_and_double_splat_args
110
126
  end
111
- CLASS
112
- # rubocop:enable Style/DocumentDynamicEvalDefinition
113
- # rubocop:enable Security/Eval
127
+ HEREDOC
114
128
  end
115
129
 
116
130
  # We pre-create argument lists for our memoized methods with arguments, so that
117
131
  # our benchmarks are running the exact same inputs for each case.
118
- N_UNIQUE_ARGUMENTS = 100
132
+ #
133
+ # NOTE: The proportion of falsey results is 1/N_UNIQUE_ARGUMENTS (because for
134
+ # the methods with arguments we are truthy for all but the first unique argument
135
+ # set, and for zero-arity methods we manually execute `no_args` N_TRUTHY_RESULTS
136
+ # times per each execution of `no_args_falsey`). This number was selected as the
137
+ # lowest number such that this logic:
138
+ #
139
+ # output = hash[key]
140
+ # if output || hash.key?(key)
141
+ # output
142
+ # else
143
+ # hash[key] = _original_method(...)
144
+ # end
145
+ #
146
+ # is consistently faster for cached lookups than:
147
+ #
148
+ # hash.fetch(key) do
149
+ # hash[key] = _original_method(...)
150
+ # end
151
+ #
152
+ # as a result of `Hash#[]` having less overhead than `Hash#fetch`.
153
+ #
154
+ # We believe this is a reasonable choice because we believe most memoized method
155
+ # results will be truthy, and so that is the case we should most optimize for.
156
+ # However, we do not want to completely remove falsey method results from these
157
+ # benchmarks because we do want to catch performance regressions for that case,
158
+ # since it has its own "hot path."
159
+ N_UNIQUE_ARGUMENTS = 30
119
160
  ARGUMENTS = Array.new(N_UNIQUE_ARGUMENTS) { |i| [i, i + 1] }
120
-
121
- # We benchmark different cases separately, to ensure that slow performance in
122
- # one method or code path isn't hidden by fast performance in another.
123
-
124
- Benchmark.ips do |x|
125
- x.config(suite: suite)
126
- BENCHMARK_GEMS.each do |benchmark_gem|
127
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
128
-
129
- # Run once to memoize the result value, so our benchmark only tests memoized
130
- # retrieval time.
161
+ N_TRUTHY_RESULTS = N_UNIQUE_ARGUMENTS - 1
162
+ N_RESULT_DECIMAL_DIGITS = 2
163
+
164
+ # Each method within these benchmarks is initially run once to memoize the
165
+ # result value, so our benchmark only tests memoized retrieval time.
166
+ benchmark_lambdas = [
167
+ lambda do |x, instance, benchmark_gem|
168
+ instance.no_args_falsey
131
169
  instance.no_args
132
170
 
133
- x.report("#{benchmark_gem.benchmark_name}: ()") { instance.no_args }
134
- end
135
-
136
- x.compare!
137
- end
138
-
139
- Benchmark.ips do |x|
140
- x.config(suite: suite)
141
- BENCHMARK_GEMS.each do |benchmark_gem|
142
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
143
-
144
- # Run once with each set of arguments to memoize the result values, so our
145
- # benchmark only tests memoized retrieval time.
171
+ x.report("#{benchmark_gem.benchmark_name}: ()") do
172
+ instance.no_args_falsey
173
+ N_TRUTHY_RESULTS.times { instance.no_args }
174
+ end
175
+ end,
176
+ lambda do |x, instance, benchmark_gem|
146
177
  ARGUMENTS.each { |a, _| instance.one_positional_arg(a) }
147
178
 
148
179
  x.report("#{benchmark_gem.benchmark_name}: (a)") do
149
180
  ARGUMENTS.each { |a, _| instance.one_positional_arg(a) }
150
181
  end
151
- end
152
-
153
- x.compare!
154
- end
155
-
156
- Benchmark.ips do |x|
157
- x.config(suite: suite)
158
- BENCHMARK_GEMS.each do |benchmark_gem|
159
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
160
-
161
- # Run once with each set of arguments to memoize the result values, so our
162
- # benchmark only tests memoized retrieval time.
182
+ end,
183
+ lambda do |x, instance, benchmark_gem|
163
184
  ARGUMENTS.each { |a, b| instance.positional_args(a, b) }
164
185
 
165
186
  x.report("#{benchmark_gem.benchmark_name}: (a, b)") do
166
187
  ARGUMENTS.each { |a, b| instance.positional_args(a, b) }
167
188
  end
168
- end
169
-
170
- x.compare!
171
- end
172
-
173
- Benchmark.ips do |x|
174
- x.config(suite: suite)
175
- BENCHMARK_GEMS.each do |benchmark_gem|
176
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
177
-
178
- # Run once with each set of arguments to memoize the result values, so our
179
- # benchmark only tests memoized retrieval time.
189
+ end,
190
+ lambda do |x, instance, benchmark_gem|
180
191
  ARGUMENTS.each { |a, _| instance.one_keyword_arg(a: a) }
181
192
 
182
193
  x.report("#{benchmark_gem.benchmark_name}: (a:)") do
183
194
  ARGUMENTS.each { |a, _| instance.one_keyword_arg(a: a) }
184
195
  end
185
- end
186
-
187
- x.compare!
188
- end
189
-
190
- Benchmark.ips do |x|
191
- x.config(suite: suite)
192
- BENCHMARK_GEMS.each do |benchmark_gem|
193
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
194
-
195
- # Run once with each set of arguments to memoize the result values, so our
196
- # benchmark only tests memoized retrieval time.
196
+ end,
197
+ lambda do |x, instance, benchmark_gem|
197
198
  ARGUMENTS.each { |a, b| instance.keyword_args(a: a, b: b) }
198
199
 
199
200
  x.report("#{benchmark_gem.benchmark_name}: (a:, b:)") do
200
201
  ARGUMENTS.each { |a, b| instance.keyword_args(a: a, b: b) }
201
202
  end
202
- end
203
-
204
- x.compare!
205
- end
206
-
207
- Benchmark.ips do |x|
208
- x.config(suite: suite)
209
- BENCHMARK_GEMS.each do |benchmark_gem|
210
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
211
-
212
- # Run once with each set of arguments to memoize the result values, so our
213
- # benchmark only tests memoized retrieval time.
203
+ end,
204
+ lambda do |x, instance, benchmark_gem|
214
205
  ARGUMENTS.each { |a, b| instance.positional_and_keyword_args(a, b: b) }
215
206
 
216
207
  x.report("#{benchmark_gem.benchmark_name}: (a, b:)") do
217
208
  ARGUMENTS.each { |a, b| instance.positional_and_keyword_args(a, b: b) }
218
209
  end
219
- end
220
-
221
- x.compare!
222
- end
223
-
224
- Benchmark.ips do |x|
225
- x.config(suite: suite)
226
- BENCHMARK_GEMS.each do |benchmark_gem|
227
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
228
-
229
- # Run once with each set of arguments to memoize the result values, so our
230
- # benchmark only tests memoized retrieval time.
210
+ end,
211
+ lambda do |x, instance, benchmark_gem|
231
212
  ARGUMENTS.each { |a, b| instance.positional_and_splat_args(a, b) }
232
213
 
233
214
  x.report("#{benchmark_gem.benchmark_name}: (a, *args)") do
234
215
  ARGUMENTS.each { |a, b| instance.positional_and_splat_args(a, b) }
235
216
  end
236
- end
237
-
238
- x.compare!
239
- end
240
-
241
- Benchmark.ips do |x|
242
- x.config(suite: suite)
243
- BENCHMARK_GEMS.each do |benchmark_gem|
244
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
245
-
246
- # Run once with each set of arguments to memoize the result values, so our
247
- # benchmark only tests memoized retrieval time.
217
+ end,
218
+ lambda do |x, instance, benchmark_gem|
248
219
  ARGUMENTS.each { |a, b| instance.keyword_and_double_splat_args(a: a, b: b) }
249
220
 
250
221
  x.report(
@@ -254,18 +225,8 @@ Benchmark.ips do |x|
254
225
  instance.keyword_and_double_splat_args(a: a, b: b)
255
226
  end
256
227
  end
257
- end
258
-
259
- x.compare!
260
- end
261
-
262
- Benchmark.ips do |x|
263
- x.config(suite: suite)
264
- BENCHMARK_GEMS.each do |benchmark_gem|
265
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
266
-
267
- # Run once with each set of arguments to memoize the result values, so our
268
- # benchmark only tests memoized retrieval time.
228
+ end,
229
+ lambda do |x, instance, benchmark_gem|
269
230
  ARGUMENTS.each do |a, b|
270
231
  instance.positional_splat_keyword_and_double_splat_args(a, b, b: b, a: a)
271
232
  end
@@ -279,6 +240,62 @@ Benchmark.ips do |x|
279
240
  end
280
241
  end
281
242
  end
243
+ ]
244
+
245
+ # We benchmark different cases separately, to ensure that slow performance in
246
+ # one method or code path isn't hidden by fast performance in another.
247
+ benchmark_lambdas.map do |benchmark|
248
+ json_file = Tempfile.new
249
+
250
+ Benchmark.ips do |x|
251
+ x.config(suite: suite)
252
+ BENCHMARK_GEMS.each do |benchmark_gem|
253
+ instance = Object.const_get("#{benchmark_gem.klass}Example").new
254
+
255
+ benchmark.call(x, instance, benchmark_gem)
256
+ end
257
+
258
+ x.compare!
259
+ x.json! json_file.path
260
+ end
261
+
262
+ JSON.parse(json_file.read)
263
+ end.each_with_index do |benchmark_json, i|
264
+ # We print a comparison table after we run each benchmark to copy into our
265
+ # README.md
266
+
267
+ # MemoWise will not appear in the comparison table, but we will use it to
268
+ # compare against other gems' benchmarks
269
+ memo_wise = benchmark_json.find { _1["name"].include?("MemoWise") }
270
+ benchmark_json.delete(memo_wise)
271
+
272
+ # Sort benchmarks by gem name to alphabetize our final output table.
273
+ benchmark_json.sort_by! { _1["name"] }
274
+
275
+ # Print headers based on the first benchmark_json
276
+ if i.zero?
277
+ benchmark_headers = benchmark_json.map do |benchmark_gem|
278
+ # Gem name is of the form:
279
+ # "MemoWise (1.1.0): ()"
280
+ # We use this mapping to get a header of the form
281
+ # "`MemoWise` (1.1.0)
282
+ gem_name_parts = benchmark_gem["name"].split
283
+ "`#{gem_name_parts[0]}` #{gem_name_parts[1][...-1]}"
284
+ end.join("|")
285
+ puts "|Method arguments|#{benchmark_headers}|"
286
+ puts "#{'|--' * (benchmark_json.size + 1)}|"
287
+ end
282
288
 
283
- x.compare!
289
+ output_str = benchmark_json.map do |bgem|
290
+ # "%.2f" % 12.345 => "12.34" (instead of "12.35")
291
+ # See: https://bugs.ruby-lang.org/issues/12548
292
+ # 1.00.round(2).to_s => "1.0" (instead of "1.00")
293
+ #
294
+ # So to round and format correctly, we first use Float#round and then %
295
+ "%.#{N_RESULT_DECIMAL_DIGITS}fx" %
296
+ (memo_wise["central_tendency"] / bgem["central_tendency"]).round(N_RESULT_DECIMAL_DIGITS)
297
+ end.join("|")
298
+
299
+ name = memo_wise["name"].partition(": ").last
300
+ puts "|`#{name}`#{' (none)' if name == '()'}|#{output_str}|"
284
301
  end