PERF: 2x ~ 30x faster dirty tracking

Currently, although using both dirty tracking (ivar backed and
attributes backed) on one model is not supported (doesn't fully work at
least), both dirty tracking are being performed, that is very slow.

As long as attributes backed dirty tracking is used, ivar backed dirty
tracking should not need to be performed.

I've refactored to extract new `ForcedMutationTracker` which only tracks
`force_change` to be performed for ivar backed dirty tracking, that
makes dirty tracking on Active Record 2x ~ 30x faster.

https://gist.github.com/kamipo/971dfe0891f0fe1ec7db8ab31f016435

Before:

```
Warming up --------------------------------------
            changed?     4.467k i/100ms
             changed     5.134k i/100ms
             changes     3.023k i/100ms
  changed_attributes     4.358k i/100ms
        title_change     3.185k i/100ms
           title_was     3.381k i/100ms
Calculating -------------------------------------
            changed?     42.197k (±28.5%) i/s -    187.614k in   5.050446s
             changed     50.481k (±16.0%) i/s -    246.432k in   5.045759s
             changes     30.799k (± 7.2%) i/s -    154.173k in   5.030765s
  changed_attributes     51.530k (±14.2%) i/s -    252.764k in   5.041106s
        title_change     44.667k (± 9.0%) i/s -    222.950k in   5.040646s
           title_was     44.635k (±16.6%) i/s -    216.384k in   5.051098s
```

After:

```
Warming up --------------------------------------
            changed?    24.130k i/100ms
             changed    13.503k i/100ms
             changes     6.511k i/100ms
  changed_attributes     9.226k i/100ms
        title_change    48.221k i/100ms
           title_was    96.060k i/100ms
Calculating -------------------------------------
            changed?    245.478k (±16.1%) i/s -      1.182M in   5.015837s
             changed    157.641k (± 4.9%) i/s -    796.677k in   5.066734s
             changes     70.633k (± 5.7%) i/s -    358.105k in   5.086553s
  changed_attributes     95.155k (±13.6%) i/s -    470.526k in   5.082841s
        title_change    566.481k (± 3.5%) i/s -      2.845M in   5.028852s
           title_was      1.487M (± 3.9%) i/s -      7.493M in   5.046774s
```
This commit is contained in:
Ryuta Kamizono 2019-04-09 08:28:31 +09:00
parent d4e2824d8d
commit 6b0a9de906
3 changed files with 122 additions and 128 deletions

View File

@ -1,14 +1,15 @@
# frozen_string_literal: true
require "active_support/core_ext/hash/indifferent_access"
require "active_support/core_ext/object/duplicable"
module ActiveModel
class AttributeMutationTracker # :nodoc:
OPTION_NOT_GIVEN = Object.new
def initialize(attributes)
def initialize(attributes, forced_changes = Set.new)
@attributes = attributes
@forced_changes = Set.new
@forced_changes = forced_changes
end
def changed_attribute_names
@ -18,24 +19,22 @@ module ActiveModel
def changed_values
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
if changed?(attr_name)
result[attr_name] = attributes[attr_name].original_value
result[attr_name] = original_value(attr_name)
end
end
end
def changes
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
change = change_to_attribute(attr_name)
if change
if change = change_to_attribute(attr_name)
result.merge!(attr_name => change)
end
end
end
def change_to_attribute(attr_name)
attr_name = attr_name.to_s
if changed?(attr_name)
[attributes[attr_name].original_value, attributes.fetch_value(attr_name)]
[original_value(attr_name), fetch_value(attr_name)]
end
end
@ -44,29 +43,26 @@ module ActiveModel
end
def changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN)
attr_name = attr_name.to_s
forced_changes.include?(attr_name) ||
attributes[attr_name].changed? &&
(OPTION_NOT_GIVEN == from || attributes[attr_name].original_value == from) &&
(OPTION_NOT_GIVEN == to || attributes[attr_name].value == to)
attribute_changed?(attr_name) &&
(OPTION_NOT_GIVEN == from || original_value(attr_name) == from) &&
(OPTION_NOT_GIVEN == to || fetch_value(attr_name) == to)
end
def changed_in_place?(attr_name)
attributes[attr_name.to_s].changed_in_place?
attributes[attr_name].changed_in_place?
end
def forget_change(attr_name)
attr_name = attr_name.to_s
attributes[attr_name] = attributes[attr_name].forgetting_assignment
forced_changes.delete(attr_name)
end
def original_value(attr_name)
attributes[attr_name.to_s].original_value
attributes[attr_name].original_value
end
def force_change(attr_name)
forced_changes << attr_name.to_s
forced_changes << attr_name
end
private
@ -75,45 +71,108 @@ module ActiveModel
def attr_names
attributes.keys
end
def attribute_changed?(attr_name)
forced_changes.include?(attr_name) || !!attributes[attr_name].changed?
end
def fetch_value(attr_name)
attributes.fetch_value(attr_name)
end
end
class ForcedMutationTracker < AttributeMutationTracker # :nodoc:
def initialize(attributes, forced_changes = {})
super
@finalized_changes = nil
end
def changed_in_place?(attr_name)
false
end
def change_to_attribute(attr_name)
if finalized_changes&.include?(attr_name)
finalized_changes[attr_name].dup
else
super
end
end
def forget_change(attr_name)
forced_changes.delete(attr_name)
end
def original_value(attr_name)
if changed?(attr_name)
forced_changes[attr_name]
else
fetch_value(attr_name)
end
end
def force_change(attr_name)
forced_changes[attr_name] = clone_value(attr_name) unless attribute_changed?(attr_name)
end
def finalize_changes
@finalized_changes = changes
end
private
attr_reader :finalized_changes
def attr_names
forced_changes.keys
end
def attribute_changed?(attr_name)
forced_changes.include?(attr_name)
end
def fetch_value(attr_name)
attributes.send(:_read_attribute, attr_name)
end
def clone_value(attr_name)
value = fetch_value(attr_name)
value.duplicable? ? value.clone : value
rescue TypeError, NoMethodError
value
end
end
class NullMutationTracker # :nodoc:
include Singleton
def changed_attribute_names(*)
def changed_attribute_names
[]
end
def changed_values(*)
def changed_values
{}
end
def changes(*)
def changes
{}
end
def change_to_attribute(attr_name)
end
def any_changes?(*)
def any_changes?
false
end
def changed?(*)
def changed?(attr_name, **)
false
end
def changed_in_place?(*)
def changed_in_place?(attr_name)
false
end
def forget_change(*)
end
def original_value(*)
end
def force_change(*)
def original_value(attr_name)
end
end
end

View File

@ -1,7 +1,5 @@
# frozen_string_literal: true
require "active_support/hash_with_indifferent_access"
require "active_support/core_ext/object/duplicable"
require "active_model/attribute_mutation_tracker"
module ActiveModel
@ -122,9 +120,6 @@ module ActiveModel
extend ActiveSupport::Concern
include ActiveModel::AttributeMethods
OPTION_NOT_GIVEN = Object.new # :nodoc:
private_constant :OPTION_NOT_GIVEN
included do
attribute_method_suffix "_changed?", "_change", "_will_change!", "_was"
attribute_method_suffix "_previously_changed?", "_previous_change"
@ -145,10 +140,9 @@ module ActiveModel
# +mutations_from_database+ to +mutations_before_last_save+ respectively.
def changes_applied
unless defined?(@attributes)
@previously_changed = changes
mutations_from_database.finalize_changes
end
@mutations_before_last_save = mutations_from_database
@attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
forget_attribute_assignments
@mutations_from_database = nil
end
@ -159,7 +153,7 @@ module ActiveModel
# person.name = 'bob'
# person.changed? # => true
def changed?
changed_attributes.present?
mutations_from_database.any_changes?
end
# Returns an array with the name of the attributes with unsaved changes.
@ -168,42 +162,37 @@ module ActiveModel
# person.name = 'bob'
# person.changed # => ["name"]
def changed
changed_attributes.keys
mutations_from_database.changed_attribute_names
end
# Handles <tt>*_changed?</tt> for +method_missing+.
def attribute_changed?(attr, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN) # :nodoc:
!!changes_include?(attr) &&
(to == OPTION_NOT_GIVEN || to == _read_attribute(attr)) &&
(from == OPTION_NOT_GIVEN || from == changed_attributes[attr])
def attribute_changed?(attr_name, **options) # :nodoc:
mutations_from_database.changed?(attr_name.to_s, options)
end
# Handles <tt>*_was</tt> for +method_missing+.
def attribute_was(attr) # :nodoc:
attribute_changed?(attr) ? changed_attributes[attr] : _read_attribute(attr)
def attribute_was(attr_name) # :nodoc:
mutations_from_database.original_value(attr_name.to_s)
end
# Handles <tt>*_previously_changed?</tt> for +method_missing+.
def attribute_previously_changed?(attr) #:nodoc:
previous_changes_include?(attr)
def attribute_previously_changed?(attr_name) # :nodoc:
mutations_before_last_save.changed?(attr_name.to_s)
end
# Restore all previous data of the provided attributes.
def restore_attributes(attributes = changed)
attributes.each { |attr| restore_attribute! attr }
def restore_attributes(attr_names = changed)
attr_names.each { |attr_name| restore_attribute!(attr_name) }
end
# Clears all dirty data: current changes and previous changes.
def clear_changes_information
@previously_changed = ActiveSupport::HashWithIndifferentAccess.new
@mutations_before_last_save = nil
@attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
forget_attribute_assignments
@mutations_from_database = nil
end
def clear_attribute_changes(attr_names)
attributes_changed_by_setter.except!(*attr_names)
attr_names.each do |attr_name|
clear_attribute_change(attr_name)
end
@ -216,13 +205,7 @@ module ActiveModel
# person.name = 'robert'
# person.changed_attributes # => {"name" => "bob"}
def changed_attributes
# This should only be set by methods which will call changed_attributes
# multiple times when it is known that the computed value cannot change.
if defined?(@cached_changed_attributes)
@cached_changed_attributes
else
attributes_changed_by_setter.reverse_merge(mutations_from_database.changed_values).freeze
end
mutations_from_database.changed_values
end
# Returns a hash of changed attributes indicating their original
@ -232,9 +215,7 @@ module ActiveModel
# person.name = 'bob'
# person.changes # => { "name" => ["bill", "bob"] }
def changes
cache_changed_attributes do
ActiveSupport::HashWithIndifferentAccess[changed.map { |attr| [attr, attribute_change(attr)] }]
end
mutations_from_database.changes
end
# Returns a hash of attributes that were changed before the model was saved.
@ -244,27 +225,23 @@ module ActiveModel
# person.save
# person.previous_changes # => {"name" => ["bob", "robert"]}
def previous_changes
@previously_changed ||= ActiveSupport::HashWithIndifferentAccess.new
@previously_changed.merge(mutations_before_last_save.changes)
mutations_before_last_save.changes
end
def attribute_changed_in_place?(attr_name) # :nodoc:
mutations_from_database.changed_in_place?(attr_name)
mutations_from_database.changed_in_place?(attr_name.to_s)
end
private
def clear_attribute_change(attr_name)
mutations_from_database.forget_change(attr_name)
mutations_from_database.forget_change(attr_name.to_s)
end
def mutations_from_database
unless defined?(@mutations_from_database)
@mutations_from_database = nil
end
@mutations_from_database ||= if defined?(@attributes)
ActiveModel::AttributeMutationTracker.new(@attributes)
else
NullMutationTracker.instance
ActiveModel::ForcedMutationTracker.new(self)
end
end
@ -276,68 +253,28 @@ module ActiveModel
@mutations_before_last_save ||= ActiveModel::NullMutationTracker.instance
end
def cache_changed_attributes
@cached_changed_attributes = changed_attributes
yield
ensure
clear_changed_attributes_cache
end
def clear_changed_attributes_cache
remove_instance_variable(:@cached_changed_attributes) if defined?(@cached_changed_attributes)
end
# Returns +true+ if attr_name is changed, +false+ otherwise.
def changes_include?(attr_name)
attributes_changed_by_setter.include?(attr_name) || mutations_from_database.changed?(attr_name)
end
alias attribute_changed_by_setter? changes_include?
# Returns +true+ if attr_name were changed before the model was saved,
# +false+ otherwise.
def previous_changes_include?(attr_name)
previous_changes.include?(attr_name)
end
# Handles <tt>*_change</tt> for +method_missing+.
def attribute_change(attr)
[changed_attributes[attr], _read_attribute(attr)] if attribute_changed?(attr)
def attribute_change(attr_name)
mutations_from_database.change_to_attribute(attr_name.to_s)
end
# Handles <tt>*_previous_change</tt> for +method_missing+.
def attribute_previous_change(attr)
previous_changes[attr]
def attribute_previous_change(attr_name)
mutations_before_last_save.change_to_attribute(attr_name.to_s)
end
# Handles <tt>*_will_change!</tt> for +method_missing+.
def attribute_will_change!(attr)
unless attribute_changed?(attr)
begin
value = _read_attribute(attr)
value = value.duplicable? ? value.clone : value
rescue TypeError, NoMethodError
end
set_attribute_was(attr, value)
end
mutations_from_database.force_change(attr)
def attribute_will_change!(attr_name)
mutations_from_database.force_change(attr_name.to_s)
end
# Handles <tt>restore_*!</tt> for +method_missing+.
def restore_attribute!(attr)
if attribute_changed?(attr)
__send__("#{attr}=", changed_attributes[attr])
clear_attribute_changes([attr])
def restore_attribute!(attr_name)
attr_name = attr_name.to_s
if attribute_changed?(attr_name)
__send__("#{attr_name}=", attribute_was(attr_name))
clear_attribute_change(attr_name)
end
end
def attributes_changed_by_setter
@attributes_changed_by_setter ||= ActiveSupport::HashWithIndifferentAccess.new
end
# Force an attribute to have a particular "before" value
def set_attribute_was(attr, old_value)
attributes_changed_by_setter[attr] = old_value
end
end
end

View File

@ -29,9 +29,7 @@ module ActiveRecord
# <tt>reload</tt> the record and clears changed attributes.
def reload(*)
super.tap do
@previously_changed = ActiveSupport::HashWithIndifferentAccess.new
@mutations_before_last_save = nil
@attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
@mutations_from_database = nil
end
end
@ -51,7 +49,7 @@ module ActiveRecord
# +to+ When passed, this method will return false unless the value was
# changed to the given value
def saved_change_to_attribute?(attr_name, **options)
mutations_before_last_save.changed?(attr_name, **options)
mutations_before_last_save.changed?(attr_name.to_s, options)
end
# Returns the change to an attribute during the last save. If the
@ -63,7 +61,7 @@ module ActiveRecord
# invoked as +saved_change_to_name+ instead of
# <tt>saved_change_to_attribute("name")</tt>.
def saved_change_to_attribute(attr_name)
mutations_before_last_save.change_to_attribute(attr_name)
mutations_before_last_save.change_to_attribute(attr_name.to_s)
end
# Returns the original value of an attribute before the last save.
@ -73,7 +71,7 @@ module ActiveRecord
# invoked as +name_before_last_save+ instead of
# <tt>attribute_before_last_save("name")</tt>.
def attribute_before_last_save(attr_name)
mutations_before_last_save.original_value(attr_name)
mutations_before_last_save.original_value(attr_name.to_s)
end
# Did the last call to +save+ have any changes to change?
@ -101,7 +99,7 @@ module ActiveRecord
# +to+ When passed, this method will return false unless the value will be
# changed to the given value
def will_save_change_to_attribute?(attr_name, **options)
mutations_from_database.changed?(attr_name, **options)
mutations_from_database.changed?(attr_name.to_s, options)
end
# Returns the change to an attribute that will be persisted during the
@ -115,7 +113,7 @@ module ActiveRecord
# If the attribute will change, the result will be an array containing the
# original value and the new value about to be saved.
def attribute_change_to_be_saved(attr_name)
mutations_from_database.change_to_attribute(attr_name)
mutations_from_database.change_to_attribute(attr_name.to_s)
end
# Returns the value of an attribute in the database, as opposed to the
@ -127,7 +125,7 @@ module ActiveRecord
# saved. It can be invoked as +name_in_database+ instead of
# <tt>attribute_in_database("name")</tt>.
def attribute_in_database(attr_name)
mutations_from_database.original_value(attr_name)
mutations_from_database.original_value(attr_name.to_s)
end
# Will the next call to +save+ have any changes to persist?