-
Notifications
You must be signed in to change notification settings - Fork 21.4k
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
Use transform_values to build hash_rows #37614
Conversation
row = [1, 2, 3, "Top 10 tricks to speed up hashes", "by John Hawthorn", "transform_values is quick and even faster in Ruby 2.7. Try it today!", Time.now, Time.now, Time.now] lol! Very much approved on this, though I'm not seeing where |
This commit changes hash_rows to build the first row as it did before, and then to use transform_values to build subsequent rows, using the first as a template. In Ruby 2.4+ (first version to include transform_values) this is marginally faster because the hash will never be realloc'd and the hash update logic is probably slightly simpler than add. In Ruby 2.7+ this is a fair bit faster because transform_values is able to run without hashing the keys and only iterates over the values list. In the case that we only have one row this is only one extra lvar set/read and the condition.
b050aa7
to
d495eae
Compare
🤦♂ fixed |
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.
Sweet!
end | ||
else | ||
# In the past we used Hash[columns.zip(row)] | ||
# though elegant, the verbose way is much more efficient |
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.
Should this indentation be fixed?
Tracked the 2.7 performance difference down to this commit ruby/ruby@72825c3 so the performance difference is intentional and we're just hitting the case where it's slower (hash construction) and my 9-column example happens to be the worst case 😅. In most cases that optimization is probably well worth it. |
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. |
index = 0 | ||
while index < length | ||
hash[columns[index]] = row[index] | ||
index += 1 | ||
end |
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.
@vinistock since you've been doing some perf work, curious if you want to test if either of these are improvements speed/allocation wise:
hash = {}
0.upto length do |index|
hash[columns[index]] = row[index]
end
hash = {}
columns.each_with_index do |column, index|
hash[column] = row[index]
end
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.
Absolutely! I benchmarked the current implementation vs the two options you suggested. For this benchmark, I removed the memoization using the instance variable.
It appears that the current implementation performs better than the others. And memory wise, it looks like they all perform equally.
Script
# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem "rails", github: "rails/rails", require: "rails/all"
gem "benchmark-ips"
gem "benchmark-memory", require: "benchmark/memory"
end
module ActiveRecord
class Result
private
def hash_rows
columns = @columns.map(&:-@)
length = columns.length
template = nil
@rows.map { |row|
if template
index = -1
template.transform_values do
row[index += 1]
end
else
hash = {}
index = 0
while index < length
hash[columns[index]] = row[index]
index += 1
end
template = hash if hash.length == length
hash
end
}
end
def upto_hash_rows
columns = @columns.map(&:-@)
length = columns.length
template = nil
@rows.map { |row|
if template
index = -1
template.transform_values do
row[index += 1]
end
else
hash = {}
0.upto length do |index|
hash[columns[index]] = row[index]
end
template = hash if hash.length == length
hash
end
}
end
def each_hash_rows
columns = @columns.map(&:-@)
length = columns.length
template = nil
@rows.map { |row|
if template
index = -1
template.transform_values do
row[index += 1]
end
else
hash = {}
columns.each_with_index do |column, index|
hash[column] = row[index]
end
template = hash if hash.length == length
hash
end
}
end
end
end
result = ActiveRecord::Result.new((0..50).map { |i| "col_#{i}" }, (0..1000).map { |row| (0..50).map { |col| "row_#{row}_col_#{col}" } })
Benchmark.ips do |x|
x.report("hash_rows") { result.send(:hash_rows) }
x.report("upto_hash_rows") { result.send(:upto_hash_rows) }
x.report("each_hash_rows") { result.send(:each_hash_rows) }
x.compare!
end
Benchmark.memory do |x|
x.report("hash_rows") { result.send(:hash_rows) }
x.report("upto_hash_rows") { result.send(:upto_hash_rows) }
x.report("each_hash_rows") { result.send(:each_hash_rows) }
x.compare!
end
Results
Warming up --------------------------------------
hash_rows 18.000 i/100ms
upto_hash_rows 13.000 i/100ms
each_hash_rows 25.000 i/100ms
Calculating -------------------------------------
hash_rows 277.793 (±20.5%) i/s - 1.242k in 5.011782s
upto_hash_rows 148.981 (±14.8%) i/s - 715.000 in 5.066219s
each_hash_rows 277.071 (±20.2%) i/s - 1.275k in 5.059477s
Comparison:
hash_rows: 277.8 i/s
each_hash_rows: 277.1 i/s - same-ish: difference falls within error
upto_hash_rows: 149.0 i/s - 1.86x slower
Calculating -------------------------------------
hash_rows 1.772M memsize ( 0.000 retained)
1.054k objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
upto_hash_rows 1.772M memsize ( 0.000 retained)
1.054k objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
each_hash_rows 1.772M memsize ( 0.000 retained)
1.054k objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Comparison:
hash_rows: 1772296 allocated
upto_hash_rows: 1772296 allocated - same
each_hash_rows: 1772296 allocated - same
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.
Awesome, thanks!
hash_rows: 277.8 i/s
each_hash_rows: 277.1 i/s - same-ish: difference falls within error
Seems like the each_with_index
version is just as fast? I do like that we'd remove the index
local variable reuse between the two large conditional branches.
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.
Using while
is a common optimization pattern to avoid each block evaluation.
That is a reason that didn't use each_with_index
in this case.
Now, that part is only onece evaluated by resusing the first column hash template, so no longer extremely important the avoiding block evaluation.
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.
Using
while
is a common optimization pattern to avoid each block evaluation.
Yeah, totally. It's just that if the block execution isn't slower and doesn't allocate more, then… it's not exactly optimizing that much? In any case I'm fine either way.
Last big refactor was in rails#37614. Somewhat extracted from rails#51744. The concern about columns with the same name didn't make much sense to me because in both code paths, the last one wins, so we can simplify the whole methods. Additionally by implementing `columns_index`, we have a decent template always available.
Last big refactor was in rails#37614. Somewhat extracted from rails#51744. The concern about columns with the same name didn't make much sense to me because in both code paths, the last one wins, so we can simplify the whole methods. Additionally by implementing `columns_index`, we have a decent template always available.
Last big refactor was in rails#37614. Somewhat extracted from rails#51744. The concern about columns with the same name didn't make much sense to me because in both code paths, the last one wins, so we can simplify the whole methods. Additionally by implementing `columns_index`, we have a decent template always available.
Last big refactor was in rails#37614. Somewhat extracted from rails#51744. The concern about columns with the same name didn't make much sense to me because in both code paths, the last one wins, so we can simplify the whole methods. Additionally by implementing `columns_index`, we have a decent template always available.
This commit changes hash_rows to build the first row as it did before, and then to use transform_values to build subsequent rows, using the first as a template.
In Ruby 2.4+ (first version to include transform_values) this is a tiny bit faster because the hash will never be realloc'd and the hash update logic is probably slightly simpler than add.
In Ruby 2.7+ this is a fair bit faster because transform_values is able to run without hashing the keys and only iterates over the values list.
In the case that we only have one row this is only one extra lvar set/read and the condition.
Benchmark
I made a graph to show the current Rails 6.0 implementation vs transform_values on Ruby 2.6 and 2.7. Interestingly, the current Rails 6.0 implementation is actually slower on Ruby 2.7 (a regression I'm looking into), but that shouldn't affect the motivation for this change.
This is about 7% faster on Ruby 2.6 and Ruby 2.7+transform_values is 25% faster than Ruby 2.6+current implementation (using this measurement because Ruby 2.7's hash perf regression)