New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix performance of CollectionAssociation replace_on_target and include #46652
base: main
Are you sure you want to change the base?
Conversation
@@ -516,6 +519,11 @@ def find_by_scan(*args) | |||
load_target.select { |r| ids.include?(r.id.to_s) } | |||
end | |||
end | |||
|
|||
def index_in_target(record) | |||
@target_index_map ||= @target.each_with_object({}).with_index { |(r, memo), i| memo[r] ||= i } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code this was replacing was @target.index(record)
which would return the first index for duplicate records so this implementation keeps that behavior.
@@ -465,6 +467,7 @@ def replace_on_target(record, skip_callbacks, replace:, inversing: false) | |||
target[index] = record |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't we set @target_index_map[record]
here too?
There's also a few places in this file where @target
is set directly, but it's not clear to me if @target_index_map
actually needs to be reset in those cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The two places in this method where index
could have been set both look in @target_index_map[record]
so there's no need to update it.
There's two other memoized objects, @association_ids
and @replaced_or_added_targets
so I added updating/resetting of @target_index_map
to wherever we update/reset those, which is the reset
, remove_records
, and replace_on_target
methods.
Looking through the control flow here I think that's all that's needed. The two places where @target
is set directly is:
load_target
where the association would've either never been loaded or would have been resetreplace_records
, where we first calldelete
which callsreset
. Actually, looking at it, I think it's possible for association_ids to also get into a bad state in that method. Going to try to repro with a test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually think association_ids
is OK because we null it out. For @target_index_map
we need to be more careful since we update it in place in replace_on_target
. We could also just null it out there, but that would produce an n-squared when writing new records which wouldn't be ideal for large collections.
I think the safest thing to do is just reset the target_index_map whenever we explicitly set target, which is only those two place so it seems fine to do. This way there's no footgun lying around and you don't have to make any assumptions on when the collection would've last been reset.
This reminds me of a similar regression we saw when upgrading to 6.1. Just sharing in case that fix is helpful: #42524 |
activerecord/lib/active_record/associations/collection_association.rb
Outdated
Show resolved
Hide resolved
Please squash your commits. |
590887e
to
04b404e
Compare
04b404e
to
7cb55dc
Compare
I squashed the commits into one (including the suggested change) and just pushed it up. I did make one other change, which was for the usages of |
These numbers are inaccurate due to the
When the benchmark block accepts an argument, Would you mind re-running the benchmarks without the block parameters? Also, does your first benchmark represent a real world use case? I had been trying to refactor some of this code in #46652. I haven't merged that PR yet (because it caused a failure in Shopify's test suite that has yet to be investigated), but it has a benchmark script based on the performance concerns from #42524. Would you mind trying that benchmark with your code? (You may need to add a few |
@jonathanhefner - Thanks for the reply and pointing out the issues with the benchmarks. I'll update my scripts and post the new benchmarks. Also, in your comment I think you may have referenced the wrong PR? You said:
which is this PR. Did you mean to link #45399 by chance or maybe a different one? Or maybe I misunderstood the comment.
Do you mean does Given this performance issue we uncovered, we've refactored that code to manually create/remove the join records since we can use bulk operations like |
@jonathanhefner - I re-ran the benchmarks and updated the PR description with the correct scripts and results. Thanks again for pointing out the mistake with the block variable, billion-x improvements were definitely suspicious 😅. Speed improvements for
Speed improvements for replace_on_target:
I tried running the benchmarks from #45399, here are my results: Results with
Results with this branch:
Also, just to have more data and be able to more easily compare, I pulled down the
|
Yes, that's the one! Sorry, I was juggling tabs and copy-pasted the wrong link.
In that case, maybe an optimization local to index e1125ff8dc..f84d735afc 100644
--- a/activerecord/lib/active_record/associations/collection_association.rb
+++ b/activerecord/lib/active_record/associations/collection_association.rb
@@ -414,10 +414,13 @@ def replace_records(new_target, original_target)
end
def replace_common_records_in_memory(new_target, original_target)
- common_records = intersection(new_target, original_target)
- common_records.each do |record|
- skip_callbacks = true
- replace_on_target(record, skip_callbacks, replace: true)
+ original_target_indexes = original_target.each_with_index.to_h
+
+ new_target.each do |record|
+ next unless (index = original_target_indexes[record])
+
+ set_inverse_instance(record)
+ target[index] = record
end
end That ignores I'm just wary of introducing more complexity to this file. In particular, maintaining the invariants of Also, you might find that a local optimization improves performance even more. |
Totally hear you on that. Another thought was to make The more localized change you suggested is interesting. I'll play around with that approach and see what is looks like. |
Motivation / Background
In some production timing traces we noticed large gaps where code that looked like
order.items = items
(i.e. setting an association) seemed to be hanging. In this instance the association had ~7k items and it hung for around 30 seconds. Even more surprising is that no new records were being added/removed - it was essentiallyorder.items = order.items
.In profiling the code, we uncovered an n-squared lookup that this PR fixes. The high-level results in iterations/second:
Detail
The n-squared lookup was due to how the code determined the index of an existing record. We looped through every record we were trying to set that was in the existing list, and then tried to find its index using
target.index(record)
which would potentially have to scan the entire array.This PR introduces a method for finding the index of a record that will build a hash of record-to-index on the first call so subsequent calls are constant time hash lookups.
I also noticed the same array scan behavior in
order.items.include?(item)
that could trivially be replaced with the sameindex_for_record
method so this PR updates that as well. For include the speed improvements were:See the associated benchmarks
Benchmark script for `replace_on_target`
Before
After
Speed improvements:
Benchmark script for `include?`
Before
After
Speed improvements:
Checklist
Before submitting the PR make sure the following are checked:
[Fix #issue-number]