26 November 2007
Optimising Symbol#to_proc
Whilst pairing with Paul the other day I noticed that he preferred not to use Symbol#to_proc; on asking why, he told me it was because of the unnecessary performance hit that Symbol#to_proc imposed.
Now I’m not one for premature optimisation, but with an idiom like Symbol#to_proc likely to be used throughout a codebase, performance hits like this add up and as things stand, the Rails implementation of Symbol#to_proc is pretty expensive:
require 'benchmark'
require 'rubygems'
require 'active_support'
BIG_ARRAY = ['x'] * 1000000
Benchmark.bm do |bm|
bm.report("Standard block") do
BIG_ARRAY.map { |c| c.upcase }
end
bm.report("Symbol#to_proc") do
BIG_ARRAY.map(&:upcase)
end
end
Output on my 2Ghz quad-core Mac Pro:
user system total real
Standard block 0.720000 0.060000 0.780000 ( 0.772927)
Symbol#to_proc 3.030000 0.010000 3.040000 ( 3.040889)
Ouch. That’s roughly four times slower. -Based on my naive understanding of how Symbol#to_proc was implemented, it figured that the bottleneck was the creation of a new Proc object for every iteration; the proc doesn’t need to change for each iteration so surely we could just memoize it-?
Update: It seems that my initial assumption was incorrect; to_proc is in fact only called once. The real issue here is not the instantiation of a new proc, but the Rails implementation. Rails uses this slightly more complicated implementation in order to support passing of multiple arguments with the method call:
Proc.new { |*args| args.shift.__send__(self, *args) }
This allows you to do a few neat things with multiple elements of a collection like [1, 2, 3].inject(&:+) but I consider this supporting an edge case at the expense of performance.
I’ve never found myself in need of the functionality provided by Rails’ implementation (I didn’t even know it was supported) but I do find myself using the obj.map(&:method) idiom a lot so the following simplified implementation suits me just fine:
class Symbol
def to_proc
proc { |obj| obj.__send__(self) }
end
end
The performance gain is significant:
user system total real
Symbol#to_proc 0.910000 0.010000 0.920000 ( 0.916718)
The implementation itself is trivial but I’ve made it available on pastie – just drop it into a file somewhere in your Rails lib folder. If I find the time, I will try and package it up as a basic Rails plugin too. It’s worth bearing in mind that Ruby 1.9’s implementation will probably support the passing of arguments like the Rails implementation but hopefully it should be much faster.
The performance of Symbol#to_proc has also been brought up on Pratik Naik’s blog and this Rails ticket.
Update: Some of my original assumptions about the way Ruby invokes to_proc were incorrect and I have updated my article accordingly.
Return to home page | Check out my tumblelog
10 Comments on this article
Return to home page | Check out my tumblelog
Commenting on this article is now closed
1. Comment by Zach Dennis on 26 Nov 2007 at 18:11
A great tip with working code to go with it! Thanks Luke,
2. Comment by Brian on 27 Nov 2007 at 23:11
I like the way you define your PROCs. Thanks for cool code Luke,
Regards, Brian
3. Comment by Tore Darell on 28 Nov 2007 at 16:11
For the
.map(&:method)scenario there is also thepluckmethod, which I know Prototype has:It’s not in Ruby core or Rails, but the implementation is simple:
I think it’s a lot clearer (less magical) than map(&:to_s).
4. Comment by Luke Redpath on 28 Nov 2007 at 16:11
I’m not sure that really gains you much other than not having to use the & which some people find ugly (it doesn’t bother me). It also means introducing a non-standard method which is potentially confusing for future maintainers of your code.
You can always use #collect instead of #map if you feel that makes more sense semantically.
5. Comment by Floehopper on 02 Dec 2007 at 18:12
You might want to have a look at a blog post from earlier this year [1] and the subsequent Rails ticket [2] which suggested something similar to your change – it was originally named “enumerator(&:symtoproc) performance improvement”.
The patch was rejected because it broke backwards compatibility. The example given when the original change [3] looks quite neat…
{1 => “one”, 2 => “two”, 3 => “three”}.sort_by(&:first).map(&:last) #=> [“one”, “two”, “three”]
[1] http://m.onkey.org/2007/6/30/let-s-start-with-wtf [2] http://dev.rubyonrails.org/ticket/8818 [3] http://dev.rubyonrails.org/ticket/5295
6. Comment by Luke Redpath on 02 Dec 2007 at 19:12
James, my article actually contains links to that blog post. I understand that it breaks backwards compatibility but I’d rather take the performance increase over support for an edge case which can still be accomplished using regular blocks.
7. Comment by Darmowe on 02 Dec 2007 at 20:12
I as well appreciate the the way you define your procs. That will come in handy to some of my projects.
8. Comment by Webdesign Agentur on 07 Dec 2007 at 15:12
Thanks for the obj.map (method)
class Symbol def to_proc proc { |obj| obj.send(self) } end end Thanks for share this article
9. Comment by angele on 20 Dec 2007 at 07:12
Floe, I liked your trick, I mean this: {1 => “one”, 2 => “two”, 3 => “three”}.sort_by(&:first).map(&:last) #=> [“one”, “two”, “three”]
Good stuff! Thanks
10. Comment by Suchmaschinenoptimierung on 24 Dec 2007 at 00:12
Thanks man, just what I was looking for. Worked like a charm Thanks so much…