Skip to content

Conversation

moberegger
Copy link

Re-implements rails#591 for our fork.

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.

end

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

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 requested review from mscrivo and Insomniak47 May 30, 2025 19:10
@moberegger moberegger changed the title Save memory allocation when calling render Optimize memory allocation when rendering partials May 30, 2025
@Insomniak47
Copy link

Insomniak47 commented May 30, 2025

Mind linking the profiles?

Also did you start taking snapshots of real perf so we can track the differences?

@moberegger moberegger merged commit 72ee68c into main Jun 3, 2025
30 checks passed
@moberegger moberegger deleted the moberegger/optimize_options_merges branch June 13, 2025 18:54
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