From c3f00e3e8d67ff5d6470315ad137166177f079e2 Mon Sep 17 00:00:00 2001 From: Marc Siegel Date: Wed, 22 Mar 2017 10:24:56 -0400 Subject: [PATCH 1/2] Optimize `#with` This PR is inspired by https://github.com/tcrayford/Values/pull/56, and assumes that code will be merged, so uses it in the benchmarks here: https://gist.github.com/ms-ati/fa8002ef8a0ce00716e9aa6510d3d4d9 It is common in our code, as in any idiomatic code using value objects in loops or pipelines, to call `#with` many times, returning a new immutable object each time with 1 or more fields replaced with new values. The optimizations in this PR eliminate a number of extra Hash and Array instantiations that were occurring each time, in favor of iterating only over the constant `VALUE_ATTRS` array and doing key lookups in the given Hash parameter in the hot paths. Per the gist above, this increases ips (iterations per second) 2.29x, from 335.9 to 769.6 on my machine. --- lib/values.rb | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/values.rb b/lib/values.rb index 3c8af8a..6bc5874 100644 --- a/lib/values.rb +++ b/lib/values.rb @@ -49,13 +49,15 @@ def initialize(*values) const_set :VALUE_ATTRS, fields def self.with(hash) - unexpected_keys = hash.keys - self::VALUE_ATTRS - if unexpected_keys.any? + num_recognized_keys = self::VALUE_ATTRS.count { |field| hash.key?(field) } + + if num_recognized_keys != hash.size + unexpected_keys = hash.keys - self::VALUE_ATTRS raise ArgumentError.new("Unexpected hash keys: #{unexpected_keys}") end - missing_keys = self::VALUE_ATTRS - hash.keys - if missing_keys.any? + if num_recognized_keys != self::VALUE_ATTRS.size + missing_keys = self::VALUE_ATTRS - hash.keys raise ArgumentError.new("Missing hash keys: #{missing_keys} (got keys #{hash.keys})") end @@ -94,9 +96,22 @@ def pretty_print(q) end end + # Optimized to avoid intermediate Hash instantiations. def with(hash = {}) return self if hash.empty? - self.class.with(to_h.merge(hash)) + + num_recognized_keys = self.class::VALUE_ATTRS.count { |field| hash.key?(field) } + + if num_recognized_keys != hash.size + unexpected_keys = hash.keys - self.class::VALUE_ATTRS + raise ArgumentError.new("Unexpected hash keys: #{unexpected_keys}") + end + + args = self.class::VALUE_ATTRS.map do |field| + hash.key?(field) ? hash[field] : send(field) + end + + self.class.new(*args) end def to_h From a627364c8d1f25fba70f7f073b5b13ea2962ca7f Mon Sep 17 00:00:00 2001 From: Marc Siegel Date: Fri, 7 Apr 2017 16:07:09 -0400 Subject: [PATCH 2/2] Optimize `#eql?` Speed improvements: | When you have... | Delta IPS | | -------------------- | --------- | | Same instance | 15.6x | | Different last field | 10.2x | | Equal fields | 1.4x | Benchmarking gist: https://gist.github.com/ms-ati/3e62cca57ca32cd17ad2e2e2b14cc65e Explanation of changes: It came as a surprise to see the humble `#eql?` comparison in profiling -- we had not realized that in Ruby, if one wants to short-circuit comparison of a single instance of an object to itself, for example, that this logic must be explicitly added to `#eql?`. This accounts for *15.6x* difference in iterations-per-second of same-instance comparison. Furthermore, the cost of pre-calculating `hash` is already being paid at instantation of every value object. So we add an early exit if the hash does not match. This accounts for *10.2x* difference in iterations-per-second of comparisons of instances that differ in only the last field. Finally, the cost of instantiating two temporary Array instances is significant when comparing two distinct instances which have equal field values. So we iterate the field comparisons without the temporary Arrays. This accounts for the *1.4x* difference in iterations-per-second of comparisons of distinct instances with equal field values. --- lib/values.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/values.rb b/lib/values.rb index 6bc5874..f3c8532 100644 --- a/lib/values.rb +++ b/lib/values.rb @@ -68,8 +68,17 @@ def ==(other) eql?(other) end + # Optimized to check for same instance and for different hash code, and + # avoids intermediate Array instantiation to check fields. def eql?(other) - self.class == other.class && values == other.values + self.equal?(other) || + ( + self.class == other.class && + self.hash == other.hash && + self.class::VALUE_ATTRS.all? do |field| + send(field) == other.send(field) + end + ) end def values