Skip to content

Commit 10dfdc5

Browse files
committed
Index Result rows rather than to convert them into hashes
Using the same benchmark as rails#51726 A significant part of the memory footprint comes from `Result#each`: ``` Total allocated: 4.61 MB (43077 objects) Total retained: 3.76 MB (27621 objects) allocated memory by file ----------------------------------- 391.52 kB activerecord/lib/active_record/result.rb retained memory by file ----------------------------------- 374.40 kB activerecord/lib/active_record/result.rb ``` Rows are initially stored as arrays, but `Result#each` convert them to hashes. Depending on how many elements they contain, hashes use between 2 and 5 times as much memory than arrays. | Length | Hash | Array | Diff | | --------- | --------- | --------- | --------- | | 1 | 160B | 40B | -120B | | 2 | 160B | 40B | -120B | | 3 | 160B | 40B | -120B | | 4 | 160B | 80B | -80B | | 5 | 160B | 80B | -80B | | 6 | 160B | 80B | -80B | | 7 | 160B | 80B | -80B | | 8 | 160B | 80B | -80B | | 9 | 464B | 160B | -304B | | 10 | 464B | 160B | -304B | | 11 | 464B | 160B | -304B | | 12 | 464B | 160B | -304B | | 13 | 464B | 160B | -304B | | 14 | 464B | 160B | -304B | | 15 | 464B | 160B | -304B | | 16 | 464B | 160B | -304B | | 17 | 912B | 160B | -752B | | 18 | 912B | 160B | -752B | | 19 | 912B | 320B | -592B | | 20 | 912B | 320B | -592B | | 21 | 912B | 320B | -592B | | 22 | 912B | 320B | -592B | | 23 | 912B | 320B | -592B | | 24 | 912B | 320B | -592B | | 25 | 912B | 320B | -592B | | 26 | 912B | 320B | -592B | | 27 | 912B | 320B | -592B | | 28 | 912B | 320B | -592B | | 29 | 912B | 320B | -592B | | 30 | 912B | 320B | -592B | | 31 | 912B | 320B | -592B | | 32 | 912B | 320B | -592B | | 33 | 1744B | 320B | -1424B | | 34 | 1744B | 320B | -1424B | | 35 | 1744B | 320B | -1424B | | 36 | 1744B | 320B | -1424B | | 37 | 1744B | 320B | -1424B | | 38 | 1744B | 320B | -1424B | | 39 | 1744B | 640B | -1104B | | 40 | 1744B | 640B | -1104B | | 41 | 1744B | 640B | -1104B | | 42 | 1744B | 640B | -1104B | | 43 | 1744B | 640B | -1104B | | 44 | 1744B | 640B | -1104B | | 45 | 1744B | 640B | -1104B | | 46 | 1744B | 640B | -1104B | | 47 | 1744B | 640B | -1104B | | 48 | 1744B | 640B | -1104B | | 49 | 1744B | 640B | -1104B | | 50 | 1744B | 640B | -1104B | Rather than to convert rows into hashes, we can loopkup the column index into a single Hash common to all rows. To not complexify the code too much, rather than to pass the row array and the column index, we wrap both into an `IndexedRow` object, which uses an extra `40B` object, but that's still less memory even in the worst case. After: ``` Total allocated: 4.32 MB (43079 objects) Total retained: 3.65 MB (29725 objects) allocated memory by file ----------------------------------- 101.66 kB activerecord/lib/active_record/result.rb retained memory by file ----------------------------------- 84.70 kB activerecord/lib/active_record/result.rb ``` As for access speed, it's of course a bit slower, but not by much, it's between `1.5` and `2` times slower, but remains in the 10's of M iterations per second, so I think this overhead is negligible compared to all the work needed to access a model attribute. Also the `LazyAttributeSet` class only access this once, after the attribute is casted, the resulting value is still stored in a regular `Hash`.
1 parent e2ef1d6 commit 10dfdc5

File tree

2 files changed

+70
-7
lines changed

2 files changed

+70
-7
lines changed

activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,8 @@ def table_structure_with_collation(table_name, basic_structure)
691691
end
692692

693693
basic_structure.map do |column|
694+
column = column.to_h
695+
694696
column_name = column["name"]
695697

696698
if collation_hash.has_key? column_name

activerecord/lib/active_record/result.rb

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,59 @@ module ActiveRecord
3636
class Result
3737
include Enumerable
3838

39+
class IndexedRow
40+
def initialize(column_indexes, row)
41+
@column_indexes = column_indexes
42+
@row = row
43+
end
44+
45+
def size
46+
@column_indexes.size
47+
end
48+
alias_method :length, :size
49+
50+
def each_key(&block)
51+
@column_indexes.each_key(&block)
52+
end
53+
54+
def keys
55+
@column_indexes.keys
56+
end
57+
58+
def ==(other)
59+
if other.is_a?(Hash)
60+
to_hash == other
61+
else
62+
super
63+
end
64+
end
65+
66+
def key?(column)
67+
@column_indexes.key?(column)
68+
end
69+
70+
def fetch(column)
71+
if index = @column_indexes[column]
72+
@row[index]
73+
elsif block_given?
74+
yield
75+
else
76+
raise KeyError, "key not found: #{column.inspect}"
77+
end
78+
end
79+
80+
def [](column)
81+
if index = @column_indexes[column]
82+
@row[index]
83+
end
84+
end
85+
86+
def to_h
87+
@column_indexes.transform_values { |index| @row[index] }
88+
end
89+
alias_method :to_hash, :to_h
90+
end
91+
3992
attr_reader :columns, :rows, :column_types
4093

4194
def self.empty(async: false) # :nodoc:
@@ -67,14 +120,16 @@ def length
67120
end
68121

69122
# Calls the given block once for each element in row collection, passing
70-
# row as parameter.
123+
# row as parameter. Each row is a Hash-like, read only object.
124+
#
125+
# To get real hashes, use +.to_a.each+.
71126
#
72127
# Returns an +Enumerator+ if no block is given.
73128
def each(&block)
74129
if block_given?
75-
hash_rows.each(&block)
130+
indexed_rows.each(&block)
76131
else
77-
hash_rows.to_enum { @rows.size }
132+
indexed_rows.to_enum { @rows.size }
78133
end
79134
end
80135

@@ -134,14 +189,13 @@ def cast_values(type_overrides = {}) # :nodoc:
134189
end
135190

136191
def initialize_copy(other)
137-
@columns = columns
138-
@rows = rows.dup
192+
@rows = rows.dup
139193
@column_types = column_types.dup
140-
@hash_rows = nil
141194
end
142195

143196
def freeze # :nodoc:
144197
hash_rows.freeze
198+
indexed_rows.freeze
145199
super
146200
end
147201

@@ -154,7 +208,7 @@ def column_indexes # :nodoc:
154208
hash[columns[index]] = index
155209
index += 1
156210
end
157-
hash
211+
hash.freeze
158212
end
159213
end
160214

@@ -167,6 +221,13 @@ def column_type(name, index, type_overrides)
167221
end
168222
end
169223

224+
def indexed_rows
225+
@indexed_rows ||= begin
226+
columns = column_indexes
227+
@rows.map { |row| IndexedRow.new(columns, row) }.freeze
228+
end
229+
end
230+
170231
def hash_rows
171232
# We use transform_values to rows.
172233
# This is faster because we avoid any reallocs and avoid hashing entirely.

0 commit comments

Comments
 (0)