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.
- checksums.yaml +4 -4
- data/.github/PULL_REQUEST_TEMPLATE.md +2 -2
- data/.github/workflows/main.yml +1 -1
- data/.gitignore +1 -0
- data/.rubocop.yml +16 -1
- data/CHANGELOG.md +55 -2
- data/Gemfile.lock +30 -30
- data/README.md +74 -19
- data/benchmarks/Gemfile +4 -2
- data/benchmarks/benchmarks.rb +149 -132
- data/lib/memo_wise/internal_api.rb +183 -155
- data/lib/memo_wise/version.rb +1 -1
- data/lib/memo_wise.rb +241 -114
- data/memo_wise.gemspec +1 -0
- metadata +3 -4
- data/benchmarks/.ruby-version +0 -1
- data/benchmarks/Gemfile.lock +0 -29
data/benchmarks/benchmarks.rb
CHANGED
@@ -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, :
|
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,
|
51
|
-
(BenchmarkGem.new(
|
52
|
-
(BenchmarkGem.new(
|
53
|
-
(BenchmarkGem.new(
|
54
|
-
(BenchmarkGem.new(
|
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
|
-
|
62
|
-
|
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.
|
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
|
-
|
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
|
-
|
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
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
instance
|
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}: ()")
|
134
|
-
|
135
|
-
|
136
|
-
|
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
|
-
|
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
|