Skip to content

Optimize memory allocation when rendering partials #591

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

Merged
merged 11 commits into from
Jul 29, 2025

Conversation

moberegger
Copy link
Contributor

@moberegger moberegger commented May 12, 2025

We're seeing calls to reverse_merge!, merge!, and merge from JbuilderTemplate come up as CPU and memory hot spots in our profiles.

The changes proposed in this PR are inspired by https://github.com/fastruby/fast-ruby#hashmerge-vs-hash-code, and favours mutating the options hash via element assignment over merge methods. This saves on both CPU and memory allocation.

Comparing options[:locals].merge!(json: self) to options[:locals][:json] = self for example produced:

ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
              merge!   839.391k i/100ms
                [] =     1.784M i/100ms
Calculating -------------------------------------
              merge!      9.049M (± 2.9%) i/s  (110.51 ns/i) -     45.327M in   5.013695s
                [] =     26.424M (± 1.3%) i/s   (37.84 ns/i) -    133.776M in   5.063578s

Comparison:
                [] =: 26424102.1 i/s
              merge!:  9048666.8 i/s - 2.92x  slower
Calculating -------------------------------------
              merge!   160.000  memsize (     0.000  retained)
                         1.000  objects (     0.000  retained)
                         0.000  strings (     0.000  retained)
                [] =     0.000  memsize (     0.000  retained)
                         0.000  objects (     0.000  retained)
                         0.000  strings (     0.000  retained)

Comparison:
                [] =:          0 allocated
              merge!:        160 allocated - Infx more

This PR replaces all instances of reverse_merge! with [] ||=, and all instances of merge! with []=. The options were already being mutated so this introduces no change in behaviour.

There are a handful of non-mutating calls to merge as well that I was hesitant to change, but upon further analysis the options hash ends up being mutated further down the call chain anyways; any instance of the options hash being merged are on code paths that render to partials which already mutate the options.

I've run some benchmarks against something simple yet representative of a template structure that would exercise some of the changes being proposed.

json.set! :posts, @posts, partial: "post", as: :post
# _post.json.jbuilder
json.extract! post, :id, :body
json.set! :authors, post.author, partial: "author", as: :author
# _author.json.jbuilder
json.set! :firstName, author.first_name
json.set! :lastName, author.last_name

The measurements below are for 100 posts, each with a single author.

CPU

ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
                 old    29.000 i/100ms
                 new    29.000 i/100ms
Calculating -------------------------------------
                 old    216.381 (±12.5%) i/s    (4.62 ms/i) -      1.073k in   5.038211s
                 new    207.935 (±15.9%) i/s    (4.81 ms/i) -      1.044k in   5.185275s
Comparison:
                 old:      216.4 i/s
                 new:      207.9 i/s - same-ish: difference falls within error

Memory

Calculating -------------------------------------
                 old   667.136k memsize (   240.000  retained)
                         7.629k objects (     3.000  retained)
                        50.000  strings (     3.000  retained)
                 new   530.097k memsize (   240.000  retained)
                         6.621k objects (     3.000  retained)
                        50.000  strings (     3.000  retained)
Comparison:
                 new:     530097 allocated
                 old:     667136 allocated - 1.26x more

I was surprised to see no difference in IPS given the earlier benchmarks, but that can be explained by actionview diluting it; this benchmark includes the entire render lifecycle which means that my code changes are only running a couple hundred times per second.

The impactful improvements is the ~20% reduction in memory. Note that the memory allocation savings would depend entirely on your template - templates rendering to fewer or no partials would see less of an improvement, templates rendering to more partials could see a much larger improvement. As your API serves requests over time, this improvement would go a long way towards saving on garbage collection cycles.

@moberegger moberegger changed the title Optimize options merging Optimize options merging May 12, 2025
end

set! name, value
_set_value name, value
Copy link
Contributor Author

@moberegger moberegger May 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can set the value directly here instead of going back in through set! with different options. A call to set! with these parameters will just end up calling _set_value anyways.

This saves a bit in processing and also avoids an extra memory allocation for *args.

options[:locals].merge! json: self
@context.render options
options[:locals][:json] = self
@context.render options, nil
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The render helper in rails will default the second parameter to {}. By providing nil here we save on that extra memory allocation.

That second parameter is intended to be the options you provide to the partial if the first param is the partial name (ex: render 'foo', options). Since the partial name is included in the options, that second parameter isn't actually used.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the kind of micro optimization that is unnecessary. Let's just not pass the argument.

@moberegger moberegger changed the title Optimize options merging Optimize memory allocation when rendering partials May 12, 2025
@@ -118,7 +118,8 @@ def array!(collection = [], *args)
options = args.first

if args.one? && _partial_options?(options)
partial! options.merge(collection: collection)
options[:collection] = collection
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now mutating the arguments of the method. We should not do it.

options[:locals].merge! json: self
@context.render options
options[:locals][:json] = self
@context.render options, nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the kind of micro optimization that is unnecessary. Let's just not pass the argument.

@@ -242,13 +244,19 @@ def _set_inline_partial(name, object, options)
value = if object.nil?
[]
elsif _is_collection?(object)
_scope{ _render_partial_with_options options.merge(collection: object) }
_scope do
options[:collection] = object
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now mutating the argument of the method. We should not do it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize this is a private method, so this might be ok, but we should make sure we aren't mutating arguments on public methods.

Copy link
Contributor Author

@moberegger moberegger Jul 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, _set_inline_partial is called in one spot, which is set!. Even though this method is private, it would still effectively mutate the options hash provided to set!.

Is whether or not it's ok to mutate just contingent on public vs private methods? Is this simply convention?

What are we trying to guard against here? Given the documented DSL with something like json.foo partial: 'foo', foo: my_foo, the key args would result in a new Hash object that is safe to mutate without impacting the call site, no? Are we trying to avoid side effects if someone instead does something like

my_options = { partial: 'foo', foo: my_foo }
json.foo my_options

Or perhaps if/when my_options is the result of an action view helper or something like that?

else
locals = ::Hash[options[:as], object]
_scope{ _render_partial_with_options options.merge(locals: locals) }
_scope do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. Argument is being mutated

@@ -259,7 +267,8 @@ def _render_explicit_partial(name_or_options, locals = {})
else
# partial! 'name', locals: {foo: 'bar'}
if locals.one? && (locals.keys.first == :locals)
options = locals.merge(partial: name_or_options)
locals[:partial] = name_or_options
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mutating the argument. We should avoid it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one I don't understand. locals is already being mutated in this method with the current logic. Further down below, a locals.delete(:as) is run, and this can happen directly against the locals argument.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a bug already. If we do:

locals = { something: 1}
partial! "something", locals
partial! "something_else, locals

The call to the second partial should not use the arguments for the first one. That delete should be removed, or we should create a copy of the arguments when entering this method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok. This was one of the reasons why I thought there were no issues mutating the options since it was technically being done already. Didn't consider that this may not have been deliberate.

I'll try to address this, too.

Copy link
Contributor Author

@moberegger moberegger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The options hash should no longer be mutating, and where it was originally doing it in _render_explicit_partial should no longer be a problem.

Comment on lines -124 to +128
partial! options.merge(collection: collection)
options = options.dup
options[:collection] = collection
_render_partial_with_options options
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prevents mutating options on array! in a way that allows us to safely mutate the options in private methods to keep the other optimizations in place.

Comment on lines -134 to +138
_set_inline_partial name, object, options
_set_inline_partial name, object, options.dup
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise here, but for set!. We can now safely mutate in _set_inline_partial to keep the other optimizations.

Comment on lines -58 to +60
_render_explicit_partial(*args)
options = args.extract_options!.dup
options[:partial] = args.first if args.present?
_render_partial_with_options options
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was able to refactor this in such a way that we no longer require _render_explicit_partial. That method was actually already mutating options, either directly via as = locals.delete(:as), or later on when it called _render_partial_with_options.

This new setup prevents that from happening and also saves on a bunch of work in _render_explicit_partial, which was basically doing things that were already handled in _render_partial_with_options.

@moberegger moberegger requested a review from rafaelfranca July 25, 2025 18:24
@rafaelfranca rafaelfranca merged commit 5f4af71 into rails:main Jul 29, 2025
18 checks passed
@rafaelfranca
Copy link
Member

Amazing work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants