Following: An analysis of memory bloat in Active Record 5.2 Discourse is looking to sponsor some work to heavily improve AR performance with Postgres in Rails 6.0.
The concept here is to add a series of “back-portable” patches to Rails 5.2 that improve performance around all sorts of areas of pain. Discourse will run all these patches in production far in advance of the Rails 6.0 release however the plan is to make sure we land all the patches for Rails 6.0.
Here is a breakdown of changes we would like to investigate from “easy” to “hard”
Native date/time casting
PG library recently added native C level date/time casting, this patch would amend AR internal type mapping code to make use of this and ensure it is default for 6.0
It would also be shipped as a possible monkey patch / extension for 5.2 that early adopters.
The patch will include benches that demonstrate what a big difference this makes (selection / plucking of date from a table)
There are very little risks here, just need to make sure that we consult with pixeltrix (Andrew White) · GitHub to ensure there is proper full parity with the PG code.
GOAL
create table things (
created_at timestamp without timezone
)
1000.times do { Thing.create(created_at: Time.now)}
# This is significantly faster with this patch in place (expecting 2x faster)
Thing.pluck(:created_at)
PG specific ActiveRecord::Result
At the moment all AR Postgres results are “adapted” via a generic ActiveRecord::Result object.
This object is not PG aware and materializes all data prior to passing it along. This causes reasonable levels of internal waste especially slowing stuff like pluck
down a lot.
By writing a specific implementation here we pave the way to other internal improvements.
We can see that a careful “rewrite” here can provide us with 2x performance for pluck.
There are some missing bits in the PG gem that will help out here:
-
Ability to get entire row as “values” (instead of N calls to getvalue)
-
Ability to defer cast a row and get a hash like objects that is already materialized
Another challenge is that the interface for ActiveRecord result is rather wide and “special” for example .empty?
will call #rows
which is somewhat odd, why do we even need this?
Overall this patch is straight forward and of minimal internal changes with enormous benefit to “pluck” and some improvement overall depending on support the PG gem gives us.
Special case AttributeSet and LazyAttributeHash for PG
This is a huge performance win:
class ActiveModel::AttributeSet::Builder
def build_from_database(values = {}, additional_types = {})
FakeSet.new values
end
switch_build_from_database :new
class FakeSet
def initialize(values)
@values = values
end
def fetch_value(key)
if block_given?
@values.fetch(key.to_s) { yield }
else
@values[key.to_s]
end
end
end
end
this trivial monkey patch can give us a very big perf bump, looking at about 15-20% percent improvement for Topic.select(:id, :title).limit(10)
It does so by reducing internal object allocations that are very expensive.
The tricky part is allowing for all the internal AR edge cases with magic custom attributes on a column and default values without compromising internal designs.
Opt-in lazy materialization
If we can couple PG::Result to models we can be 100% delayed on materialization of columns and type casting, this means that if you over select we only do very minimal amount of work.
The tricky thing is that for pathological cases this can lead to memory bloat:
$long_term = []
Topic.limit(1000).each do |t|
$long_term << t if t.id == 100 # only one topic is retained, but entire PG::Result will remain in memory
end
Though this is probably an edge case for 99.9% of apps out there, AR can not ship with an unsafe default so we would have to have this mode be an “opt-in” thing.
Lazy casting out-of-the-box (for people who do not opt in to lazy materialization)
The last piece that requires changes from the PG gem is lazy casting for all, which would mean an API like this would be exposed.
result.tuple_values(7)
# [1,2, 'abc']
result.tuple_values(7, :lazy_cast) # or result.lazy_tuples_values(7)
# same array like object except that it is "lazy" in that [1] will be the first time [1] is cast via type mapper
# Object will hold a single cstring with all the data in the raw form copied from the pg_result.