为什么在Ruby 1.8.7中Symbol#to_proc较慢?

时间:2011-06-28 02:46:33

标签: c ruby optimization ruby-1.8

Relative Performance of Symbol#to_proc in Popular Ruby Implementations指出,在MRI Ruby 1.8.7中,Symbol#to_proc比其基准测试中的替代方案慢30%到130%,但YARV Ruby 1.9中并非如此。 2。

为什么会这样? 1.8.7的创建者没有在纯Ruby中编写Symbol#to_proc

此外,是否有任何宝石可以为1.8?

提供更快的Symbol#to_proc性能

(当我使用ruby-prof时,符号#to_proc开始出现,所以我认为我不会过早优化)

3 个答案:

答案 0 :(得分:7)

1.8.7中的to_proc实现如下所示(请参阅object.c):

static VALUE
sym_to_proc(VALUE sym)
{
    return rb_proc_new(sym_call, (VALUE)SYM2ID(sym));
}

1.9.2实现(见string.c)看起来像这样:

static VALUE
sym_to_proc(VALUE sym)
{
    static VALUE sym_proc_cache = Qfalse;
    enum {SYM_PROC_CACHE_SIZE = 67};
    VALUE proc;
    long id, index;
    VALUE *aryp;

    if (!sym_proc_cache) {
        sym_proc_cache = rb_ary_tmp_new(SYM_PROC_CACHE_SIZE * 2);
        rb_gc_register_mark_object(sym_proc_cache);
        rb_ary_store(sym_proc_cache, SYM_PROC_CACHE_SIZE*2 - 1, Qnil);
    }

    id = SYM2ID(sym);
    index = (id % SYM_PROC_CACHE_SIZE) << 1;

    aryp = RARRAY_PTR(sym_proc_cache);
    if (aryp[index] == sym) {
        return aryp[index + 1];
    }
    else {
        proc = rb_proc_new(sym_call, (VALUE)id);
        aryp[index] = sym;
        aryp[index + 1] = proc;
        return proc;
    }
}

如果你剥夺了初始化sym_proc_cache的所有繁忙工作,那么你或多或少地留下了这个:

aryp = RARRAY_PTR(sym_proc_cache);
if (aryp[index] == sym) {
    return aryp[index + 1];
}
else {
    proc = rb_proc_new(sym_call, (VALUE)id);
    aryp[index] = sym;
    aryp[index + 1] = proc;
    return proc;
}

真正的区别在于1.9.2的to_proc缓存生成的Procs,而1.8.7每次调用to_proc时都会生成一个全新的Proc。除非每次迭代都在一个单独的过程中完成,否则这两者之间的性能差异将被任何基准测试放大;但是,每个进程的一次迭代会掩盖您尝试使用启动成本进行基准测试的内容。

rb_proc_new的内容看起来几乎相同(1.8.7请参阅eval.c或1.9.2请proc.c)但1.9.2可能会从rb_iterate。缓存可能是性能差异很大。

值得注意的是,符号到散列缓存是固定大小(67个条目,但我不确定67来自哪里,可能与运算符的数量有关,因此通常用于符号到-proc转换):

id = SYM2ID(sym);
index = (id % SYM_PROC_CACHE_SIZE) << 1;
/* ... */
if (aryp[index] == sym) {

如果您使用超过67个符号作为过程,或者您的符号ID重叠(模块67),那么您将无法获得缓存的全部好处。

Rails和1.9编程风格涉及很多简写:

    id = SYM2ID(sym);
    index = (id % SYM_PROC_CACHE_SIZE) << 1;

而不是更长的显式块形式:

ints = strings.collect { |s| s.to_i }
sum  = ints.inject(0) { |s,i| s += i }

鉴于(流行的)编程风格,通过缓存查找来交换内存以提高速度是有意义的。

你不太可能从gem获得更快的实现,因为gem必须替换核心Ruby功能的一大块。您可以将1.9.2缓存修补到1.8.7源代码中。

答案 1 :(得分:4)

以下普通的Ruby代码:

if defined?(RUBY_ENGINE).nil? # No RUBY_ENGINE means it's MRI 1.8.7
  class Symbol
    alias_method :old_to_proc, :to_proc

    # Class variables are considered harmful, but I don't think
    # anyone will subclass Symbol
    @@proc_cache = {}
    def to_proc
      @@proc_cache[self] ||= old_to_proc
    end
  end
end

使Ruby MRI 1.8.7 Symbol#to_proc稍微慢一些,但不如普通块或预先存在的过程快。

然而,它会使YARV,Rubinius和JRuby变慢,因此围绕monkeypatch if

使用Symbol#to_proc的缓慢不仅仅是因为MRI 1.8.7每次创建一个proc - 即使你重新使用现有的一个,它仍然比使用一个块慢。

Using Ruby 1.8 head

Size    Block   Pre-existing proc   New Symbol#to_proc  Old Symbol#to_proc
0       0.36    0.39                0.62                1.49
1       0.50    0.60                0.87                1.73
10      1.65    2.47                2.76                3.52
100     13.28   21.12               21.53               22.29

有关完整的基准和代码,请参阅https://gist.github.com/1053502

答案 2 :(得分:1)

除了不缓存proc之外,1.8.7每次调用proc时也会创建(大约)一个数组。我怀疑这是因为生成的proc创建了一个接受参数的数组 - 即使没有参数的空proc也会发生这种情况。

这是一个演示1.8.7行为的脚本。此处只有:diff值很重要,这表示数组计数增加。

# this should really be called count_arrays
def count_objects(&block)
  GC.disable
  ct1 = ct2 = 0
  ObjectSpace.each_object(Array) { ct1 += 1 }
  yield
  ObjectSpace.each_object(Array) { ct2 += 1 }
  {:count1 => ct1, :count2 => ct2, :diff => ct2-ct1}
ensure
  GC.enable
end

to_i = :to_i.to_proc
range = 1..1000

puts "map(&to_i)"
p count_objects {
  range.map(&to_i)
}
puts "map {|e| to_i[e] }"
p count_objects {
  range.map {|e| to_i[e] }
}
puts "map {|e| e.to_i }"
p count_objects {
  range.map {|e| e.to_i }
}

示例输出:

map(&to_i)
{:count1=>6, :count2=>1007, :diff=>1001}
map {|e| to_i[e] }
{:count1=>1008, :count2=>2009, :diff=>1001}
map {|e| e.to_i }
{:count1=>2009, :count2=>2010, :diff=>1}

似乎只是调用proc将为每次迭代创建数组,但是文字块似乎只创建一次数组。

但多arg块可能仍会遇到问题:

plus = :+.to_proc
puts "inject(&plus)"
p count_objects {
  range.inject(&plus)
}
puts "inject{|sum, e| plus.call(sum, e) }"
p count_objects {
  range.inject{|sum, e| plus.call(sum, e) }
}
puts "inject{|sum, e| sum + e }"
p count_objects {
  range.inject{|sum, e| sum + e }
}

示例输出。注意我们如何在#2的情况下产生双重惩罚,因为我们使用多arg块,并且还调用proc

inject(&plus)
{:count1=>2010, :count2=>3009, :diff=>999}
inject{|sum, e| plus.call(sum, e) }
{:count1=>3009, :count2=>5007, :diff=>1998}
inject{|sum, e| sum + e }
{:count1=>5007, :count2=>6006, :diff=>999}