Ruby 2.7点阵法参考使用的隐藏成本

78 阅读5分钟

注意:这种情况也适用于 "老 "#method 方法的使用。我之所以在 "点号 "上下文中提到这一点,是因为由于增加了语法糖,这种风格的编码肯定会被更多地使用。

注意:这个功能已经被恢复了。 注意:基准和优化方法仍然适用于#method 方法的使用。


对我来说,即将发布的Ruby2.7 的最有趣的功能之一是方法引用的语法糖。我喜欢将#method 方法与#then (#yield_self) 操作符一起使用,以便以类似 "流水线 "的方式组成几个函数。当你处理数据流或建立ETL管道时,它特别有用。

这里有一个例子,说明你可以如何使用它。

class Parse
  def self.call(string)
    string.to_f
  end
end

class Normalize
  def self.call(number)
    number.round
  end
end

class Transform
  def self.call(int)
    int * 2
  end
end

# Simulate a long-running data producing source with batches
# Builds a lot of stringified floats (each unique)
stream = Array.new(10_000) do |i|
  Array.new(100) { |i| "#{i}.#{i}" }
end

stream.each do |batch|
  batch
    .map(&Parse.:call)
    .map(&Normalize.:call)
    .map(&Transform.:call)
end

这很好,很清楚,很短。那么,它有什么问题呢?

嗯,错的是Ruby本身。每次你使用#method 方法引用一个方法时,Ruby都会给你一个新的#Method 类的实例。甚至当你在获取一个对象的同一实例的方法时也是如此。这还不是全部!因为我们使用的是& 操作符,每一个获取的方法引用都会在之后使用#to_proc 方法转换为一个Proc 对象。

nil.:nil?.object_id #=> 47141222633640
nil.:nil?.object_id #=> 47141222626280
nil.:nil?.object_id #=> 47141222541360

# In general
nil.:nil?.object_id == nil.:nil?.object_id #=> false
nil.:nil?.to_proc == nil.:nil?.to_proc #=> false

这意味着,当你处理大量的数据样本时,你可能会旋转出大量的对象,并付出巨大的性能代价。特别是当你在每个实体的基础上操作时。

stream.each do |batch|
  batch.each do |message|
    message
      .then(&Parse.:call)
      .then(&Normalize.:call)
      .then(&Transform.:call)
  end
end

如果你运行与上面相同的代码,但以这样的方式运行。

stream.each do |batch|
  batch.each do |message|
    Transform.call(
      Normalize.call(
        Parse.call(message)
      )
    )
  end
end

你最终会减少1200万个对象,而且你的代码运行速度会快10倍
自己看吧。

require 'benchmark/ips'

GC.disable

class Parse
  def self.call(string)
    string.to_f
  end
end

class Normalize
  def self.call(number)
    number.round
  end
end

class Transform
  def self.call(int)
    int * 2
  end
end

# Builds a lot of stringified floats (each unique)
stream = Array.new(10_000) do |i|
  Array.new(100) { |i| "#{i}.#{i}" }
end

Benchmark.ips do |x|
  x.config(time: 5, warmup: 1)

  x.report('std') do
    stream.each do |batch|
      batch.each do |message|
        Transform.call(
          Normalize.call(
            Parse.call(message)
          )
        )
      end
    end
  end

  # This case was pointed out by Vladimir Dementyev
  # See the comments for more details
  x.report('std-then') do
    stream.each do |batch|
      batch.each do |message|
        message.then do |message|
          Parse.call(message)
        end.then do |message|
          Normalize.call(message)
        end.then do |message|
          Transform.call(message)
        end
      end
    end
  end

  x.report('dot-colon') do
    stream.each do |batch|
      batch.each do |message|
        message
          .then(&Parse.:call)
          .then(&Normalize.:call)
          .then(&Transform.:call)
      end
    end
  end

  x.compare!
end

结果。

Warming up --------------------------------------
         std 1.000 i/100ms
    std-then 1.000 i/100ms
   dot-colon 1.000 i/100ms
Calculating -------------------------------------
         std 6.7190.0%) i/s - 34.000 in 5.060580s
    std-then 3.0850.0%) i/s - 16.000 in 5.187639s
   dot-colon 0.6920.0%) i/s -  4.000 in 5.824453s

Comparison:
         std: 6.7 i/s
    std-then: 3.1 i/s - 2.18x  slower
   dot-colon: 0.7 i/s - 9.70x  slower

对对象的分配也是如此。

tao1 =  GC.stat[:total_allocated_objects]

stream.each do |batch|
  batch.each do |message|
    Transform.call(
      Normalize.call(
        Parse.call(message)
      )
    )
  end
end

tao2 =  GC.stat[:total_allocated_objects]

stream.each do |batch|
  batch.each do |message|
    message.then do |message|
      Parse.call(message)
    end.then do |message|
      Normalize.call(message)
    end.then do |message|
      Transform.call(message)
    end
  end
end

tao3 =  GC.stat[:total_allocated_objects]

stream.each do |batch|
  batch.each do |message|
    message
      .then(&Parse.:call)
      .then(&Normalize.:call)
      .then(&Transform.:call)
  end
end

tao4 =  GC.stat[:total_allocated_objects]

p "Std allocated: #{tao2 - tao1}"
p "Std-then allocated: #{tao3 - tao2}"
p "Dot-colon allocated: #{tao4 - tao3}"

Std allocated: 1
Std-then allocated: 2
Dot-colon allocated: 12000002

那么,我们根本就不应该使用这个新特性(以及一般的方法引用)吗?并非如此。如果你想使用它,并且不使你的应用程序变得那么慢,你需要做两件事。

将你的方法引用记忆化

与其为每个对象(或批次)获取方法引用,不如获取一次并重新使用。

parse = Parse.:call
normalize = Normalize.:call
transform = Transform.:call

stream.each do |batch|
  batch.each do |message|
    message
      .then(&parse)
      .then(&normalize)
      .then(&transform)
  end
end

这将使你不必创建300万个对象,并使你的代码比基本代码慢7倍

将记忆化的方法转换成procs

既然Ruby无论如何都会为你做这件事(在一个循环中),为什么不更聪明一些,为他做这件事呢。

parse = Parse.:call.to_proc
normalize = Normalize.:call.to_proc
transform = Transform.:call.to_proc

stream.each do |batch|
  batch.each do |message|
    message
      .then(&parse)
      .then(&normalize)
      .then(&transform)
  end
end

这将使上面的代码只比基础代码慢2**.5倍**(通常是很好的),同时,它将为你节省几乎所有的 1200万个额外对象

点号和方法参考的进一步发展

你们中的一些人可能知道,我已经参与了这个功能。我提出并提交了一个补丁,它将使.: Method 对象冻结。这看起来似乎不多,但由于方法对象是不可变的,冻结可以为引入方法引用缓存保留一个机会,以备不时之需。

这个建议是我今年夏天在布里斯托尔与Ko1和Matz讨论后提出的。当使用#method 方法(不是语法-糖)时,由于向后兼容(我希望在这种情况下会被打破),Method 实例不会被冻结。然而,.: 会被冻结。这是一个罕见的角落案例(你为什么要这样突变一个对象?),但它确实创造了一个有趣的 "故障"。

nil.:nil? == nil.method(:nil?) #=> true
nil.:nil?.frozen? #=> true
nil.method(:nil?).frozen? #=> false

注意:我计划在2.7版本发布后,在我能够从统计学上证明大多数情况都像上面介绍的那样后,再努力增加最后方法的缓存。