From: sam.saffron@... Date: 2020-06-04T00:42:41+00:00 Subject: [ruby-core:98644] [Ruby master Feature#16897] Can a Ruby 3.0 compatible general purpose memoizer be written in such a way that it matches Ruby 2 performance? Issue #16897 has been updated by sam.saffron (Sam Saffron). @Eregon: to summarize the one point of performance that I want to address here ### Memoizing a method that has both kwargs and args: 1. Using the fastest pattern available on Ruby 2.7.1 **REQUIRING** usage of `ruby2_keywords` OldMethod args and kwargs: 944008.6 i/s 2. Using a standard pattern on 2.7.1, simply adding `**kwargs` NewMethod args and kwargs: 766935.9 i/s 3. Using a heavily optimized and verbose approach: OptimizedMethod args and kwargs: 771978.2 i/s --- I tried this which is a hybrid of your and Samuels suggestion: ``` module Memoizer def self.KEY(*args, **kwargs) [args, kwargs] end def memoize(method_name) cache = "MEMOIZE2_#{method_name}" uncached = "#{method_name}_without_cache" alias_method uncached, method_name class_eval <<~RUBY #{cache} = {} def #{method_name}(...) found = true args = Memoizer.KEY(...) data = #{cache}.fetch(args) { found = false } unless found #{cache}[args] = data = #{uncached}(...) end data end RUBY end end ``` Sadly it appears to be slowest: 696435.9 i/s I can not seem to dispatch `...` directly into fetch for arbitrary arguments: ``` class Foo HASH = {} def bar(...) HASH.fetch(...) { rand } end end foo = Foo.new puts foo.bar(1) /home/sam/Source/performance/memoize/memoize.rb:8: both block arg and actual block given ``` Memoizer needs fetch cause you may be memoizing nil. @jeremyevans0 would though argue that this is the only correct generic memoizer, but as implemented on 2.7.1 `...` is implemented in a buggy way: ``` class Foo def key(*args, **kwargs) {args: args, kwargs: kwargs} end def test(...) key(...) end end puts Foo.new.test({a: 1}) /home/sam/Source/performance/memoize/memoize.rb:11: warning: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call /home/sam/Source/performance/memoize/memoize.rb:6: warning: The called method `key' is defined here {:args=>[], :kwargs=>{:a=>1}} puts Foo.new.test(a: 1) {:args=>[], :kwargs=>{:a=>1}} I am not following what the reticence here is for introducing an `Arguments` proper object, it solves this performance very cleanly. Plus it lets as dispatch around a list of arguments cleanly maintaining args / kwargs separation without introducing a new object to communicate this concept. ``` def foo(a = 1, b: 2) puts "a: #{a} b: #{b}" end def delay(...x) if @delayed foo(...@delayed) @delayed = x else @delayed = x end end delay({b: 7}) # prints nothing delay(9999) "a: {b: 7} b: 2" ``` cc @ko1 , @mame ---------------------------------------- Feature #16897: Can a Ruby 3.0 compatible general purpose memoizer be written in such a way that it matches Ruby 2 performance? https://bugs.ruby-lang.org/issues/16897#change-85971 * Author: sam.saffron (Sam Saffron) * Status: Open * Priority: Normal ---------------------------------------- ```ruby require 'benchmark/ips' module Memoizer def memoize_26(method_name) cache = {} uncached = "#{method_name}_without_cache" alias_method uncached, method_name define_method(method_name) do |*arguments| found = true data = cache.fetch(arguments) { found = false } unless found cache[arguments] = data = public_send(uncached, *arguments) end data end end def memoize_27(method_name) cache = {} uncached = "#{method_name}_without_cache" alias_method uncached, method_name define_method(method_name) do |*args, **kwargs| found = true all_args = [args, kwargs] data = cache.fetch(all_args) { found = false } unless found cache[all_args] = data = public_send(uncached, *args, **kwargs) end data end end def memoize_27_v2(method_name) uncached = "#{method_name}_without_cache" alias_method uncached, method_name cache = "MEMOIZE_#{method_name}" params = instance_method(method_name).parameters has_kwargs = params.any? {|t, name| "#{t}".start_with? "key"} has_args = params.any? {|t, name| !"#{t}".start_with? "key"} args = [] args << "args" if has_args args << "kwargs" if has_kwargs args_text = args.map do |n| n == "args" ? "*args" : "**kwargs" end.join(",") class_eval <<~RUBY #{cache} = {} def #{method_name}(#{args_text}) found = true all_args = #{args.length === 2 ? "[args, kwargs]" : args[0]} data = #{cache}.fetch(all_args) { found = false } unless found #{cache}[all_args] = data = public_send(:#{uncached} #{args.empty? ? "" : ", #{args_text}"}) end data end RUBY end end module Methods def args_only(a, b) sleep 0.1 "#{a} #{b}" end def kwargs_only(a:, b: nil) sleep 0.1 "#{a} #{b}" end def args_and_kwargs(a, b:) sleep 0.1 "#{a} #{b}" end end class OldMethod extend Memoizer include Methods memoize_26 :args_and_kwargs memoize_26 :args_only memoize_26 :kwargs_only end class NewMethod extend Memoizer include Methods memoize_27 :args_and_kwargs memoize_27 :args_only memoize_27 :kwargs_only end class OptimizedMethod extend Memoizer include Methods memoize_27_v2 :args_and_kwargs memoize_27_v2 :args_only memoize_27_v2 :kwargs_only end OptimizedMethod.new.args_only(1,2) methods = [ OldMethod.new, NewMethod.new, OptimizedMethod.new ] Benchmark.ips do |x| x.warmup = 1 x.time = 2 methods.each do |m| x.report("#{m.class} args only") do |times| while times > 0 m.args_only(10, b: 10) times -= 1 end end x.report("#{m.class} kwargs only") do |times| while times > 0 m.kwargs_only(a: 10, b: 10) times -= 1 end end x.report("#{m.class} args and kwargs") do |times| while times > 0 m.args_and_kwargs(10, b: 10) times -= 1 end end end x.compare! end # # Ruby 2.6.5 # # # OptimizedMethod args only: 974266.9 i/s # OldMethod args only: 949344.9 i/s - 1.03x slower # OldMethod args and kwargs: 945951.5 i/s - 1.03x slower # OptimizedMethod kwargs only: 939160.2 i/s - 1.04x slower # OldMethod kwargs only: 868229.3 i/s - 1.12x slower # OptimizedMethod args and kwargs: 751797.0 i/s - 1.30x slower # NewMethod args only: 730594.4 i/s - 1.33x slower # NewMethod args and kwargs: 727300.5 i/s - 1.34x slower # NewMethod kwargs only: 665003.8 i/s - 1.47x slower # # # # # Ruby 2.7.1 # # OptimizedMethod kwargs only: 1021707.6 i/s # OptimizedMethod args only: 955694.6 i/s - 1.07x (� 0.00) slower # OldMethod args and kwargs: 940911.3 i/s - 1.09x (� 0.00) slower # OldMethod args only: 930446.1 i/s - 1.10x (� 0.00) slower # OldMethod kwargs only: 858238.5 i/s - 1.19x (� 0.00) slower # OptimizedMethod args and kwargs: 773773.5 i/s - 1.32x (� 0.00) slower # NewMethod args and kwargs: 772653.3 i/s - 1.32x (� 0.00) slower # NewMethod args only: 771253.2 i/s - 1.32x (� 0.00) slower # NewMethod kwargs only: 700604.1 i/s - 1.46x (� 0.00) slower ``` The bottom line is that a generic delegator often needs to make use of all the arguments provided to a method. ```ruby def count(*args, **kwargs) counter[[args, kwargs]] += 1 orig_count(*args, **kwargs) end ``` The old pattern meant we could get away with one less array allocation per: ```ruby def count(*args) counter[args] += 1 orig_count(*args, **kwargs) end ``` I would like to propose some changes to Ruby 3 to allow to recover this performance. Perhaps: ```ruby def count(...) args = ... counter[args] += 1 orig_count(...) end ``` Or: ```ruby def count(***args) counter[args] += 1 orig_count(***args) end ``` Thoughts? -- https://bugs.ruby-lang.org/ Unsubscribe: