4

流行的 Ruby 实现中 Symbol#to_proc 的相对性能指出,在 MRI Ruby 1.8.7 中,Symbol#to_proc比其基准测试中的替代方案慢 30% 到 130%,但在 YARV Ruby 1.9.2 中并非如此。

为什么会这样?1.8.7 的创建者并不是用Symbol#to_proc纯 Ruby 编写的。

此外,是否有任何 gem 可以为 1.8 提供更快的 Symbol#to_proc 性能?

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

4

3 回答 3

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.2to_proc缓存了生成的 Procs,而 1.8.7 每次调用时都会生成一个全新的 Procs to_proc。除非每次迭代都在单独的过程中完成,否则您所做的任何基准测试都会放大这两者之间的性能差异;但是,每个进程一次迭代会掩盖您尝试用启动成本进行基准测试的内容。

rb_proc_new看起来几乎相同(参见1.8.7eval.cproc.c1.9.2),但 1.9.2 可能会从rb_iterate. 缓存可能是最大的性能差异。

值得注意的是,符号到哈希缓存的大小是固定的(67 个条目,但我不确定 67 来自哪里,可能与运算符的数量有关,并且通常用于符号到过程的转换):

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

如果您使用超过 67 个符号作为 proc,或者您的符号 ID 重叠(mod 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 源中。

于 2011-06-28T20:49:44.720 回答
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.7Symbol#to_proc比以前慢一些,但不如普通块或预先存在的 proc 快。

但是,它会使 YARV、Rubinius 和 JRuby 变慢,因此会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

于 2011-06-29T11:26:47.283 回答
1

除了不缓存procs 之外,1.8.7 还会在每次proc调用 a 时(大约)创建一个数组。我怀疑这是因为 generateproc创建了一个数组来接受参数——即使是一个不带参数的空数组也会发生这种情况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}

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

但是多参数块可能仍然会遇到这个问题:

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 中是如何招致双重惩罚的,因为我们使用了一个多参数块,并且还调用了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}
于 2013-10-10T18:58:05.683 回答