Reduce memory consumption with a custom ActiveRecord attribute
Posted: Apr 11, 2021
In the team I work we try to see what can be improved in our projects before it hits us, performance is one of those topics. In general, I like to play with different profilers. Especially, we are on Heroku, thus if we can optimize something we can stay longer on a Performance-M plan (who really need 14 Gb of RAM on a Performance-L plan? I would like to see something in between, but Heroku's marketing team has another opinion).
Our project has one endpoint which gets called quite often. So, I profiled it with memory profiler and saw a line pointing to hstore.rb
.
allocated objects by file
-----------------------------------
2694 /usr/local/bundle/gems/activerecord-6.0.3.5/lib/active_record/connection_adapters/postgresql/oid/hstore.rb
Actually, it is a first line in this section, the total allocated memory is 1.49 Mb.
That seemed interesting. Whenever an hstore value is fetched from the DB, it gets parsed there, ActiveRecord gets rid of quoting. In our project Hstore is used mainly for keeping translations. So, every record keeps identical keys in the Hstore column, something like this:
"en"=>"English", "de"=>"Deutsch"
After scrolling down to an Allocated String Report
section, I saw this
432 "de"
The de
string was allocated 432 times.
If locale keys can be cached, we save some bytes in RAM. The first thought was to monkey patch Rails, but after exploring internals of ActiveRecord, I found that ActiveRecord supports custom types, I didn't know this feature of ActiveRecord. According to commits, this feature was added 6 years ago! So, I copy-pasted internals of ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Hstore
and adjusted it to cache keys.
module Types
class LocaleHstore < ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Hstore
def deserialize(value)
if value.is_a?(::String)
::Hash[value.scan(HstorePair).map { |k, v|
v = v.upcase == "NULL" ? nil : v.gsub(/\A"(.*)"\Z/m, '\1').gsub(/\\(.)/, '\1')
k = parse_key(k)
[k, v]
}]
else
value
end
end
def parse_key(key)
@cache_key ||= {}
return @cache_key[key] if @cache_key[key]
parsed = key.gsub(/\A"(.*)"\Z/m, '\1').gsub(/\\(.)/, '\1')
@cache_key[key] = parsed
parsed
end
end
end
ActiveRecord::Type.register(:locale_hstore, Types::LocaleHstore)
Then added it to models:
class Product < ApplicationRecord
attribute :title_i18n, :locale_hstore
end
There are more than one model keeping translations and some models have a few attributes with translations. So, the scope of this change is more than one model.
After this simple optimization "de" string was allocated 128 times, yeah, there are more parts to be reviewed, this number still looks too high. Although, the total memory consumption per request was reduced on 75,98 Kb.