memo_wise 1.0.0 → 1.4.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.
@@ -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