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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

moberegger
Copy link

@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 options are being merged several millions fewer times per second.

The impactful improvements it the ~20% 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
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
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.

@moberegger moberegger changed the title Optimize options merging Optimize memory allocation when rendering partials May 12, 2025
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.

1 participant