Skip to content

Conversation

@moberegger
Copy link

@moberegger moberegger commented Jun 13, 2025

This optimizes the array! DSL to run faster with less memory usage. The PR may look a bit bigger than it actually is, but a summary of the changes:

  • Save on a call to one?. This shows up as a hotspot for us, and it isn't actually needed. The intent here was to check if a single argument was provided to array! to determine whether or not a partial should be rendered. It's actually just faster to check if the first argument is a Hash than it is to call out to one? first, because the latter is an O(n) operation.
  • Save on calls to ::Kernel.block_given?. Because Jbuilder is a BasicObject, it does not have direct access to block_given?, so it must explicitly call out to the Kernel module instead. This is actually slower than simply explicitly checking if the block argument exists.
  • Saves on memory allocations originally caused by internal calls to array!, which splats *args into an allocated Array each time. The PR introduces _array to be used internally, which saves on this allocation. This is similar to what was done in Optimize internal extract! calls to save on memory allocation #7 for extract!.
  • Introduces a frozen EMPTY_ARRAY to save on allocating a new empty array (ie. []) each time an empty collections are rendered.

The leanest way to benchmark the array! DSL is with

json.array!(nil)

This gets the main changes running the most frequently under the benchmark without it being diluted by other things. Results look good so far!

ruby 3.4.4 (2025-05-14 revision a38531fd3f) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
              before   491.189k i/100ms
               after   727.930k i/100ms
Calculating -------------------------------------
              before      5.124M (±15.6%) i/s  (195.18 ns/i) -     24.559M in   5.025914s
               after      8.195M (± 7.9%) i/s  (122.03 ns/i) -     40.764M in   5.043596s

Comparison:
               after:  8194919.7 i/s
              before:  5123561.6 i/s - 1.60x  slower
Calculating -------------------------------------
              before   832.000  memsize (   520.000  retained)
                         8.000  objects (     4.000  retained)
                         0.000  strings (     0.000  retained)
               after   640.000  memsize (   520.000  retained)
                         7.000  objects (     4.000  retained)
                         0.000  strings (     0.000  retained)

Comparison:
               after:        640 allocated
              before:        832 allocated - 1.30x more

There are a few other spots that are now also taking advantage of these optimizations. Those have been annotated below.

Please note that all benchmarks are being compared to our fork's main branch, so they capture the differences introduced by this PR. If you like, I can also include benchmarks against upstream_main to show how all of our optimizations compare to stock jbuilder thus far.

(Still poking around the benchmarks. I will include them in the PR soon.)

Comment on lines -43 to +45
_scope{ array! value, &block }
_scope{ _array value, &block }
Copy link
Author

Choose a reason for hiding this comment

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

json.set! :posts, posts do |post|
  json.extract! post, :id, :body
end
ruby 3.4.4 (2025-05-14 revision a38531fd3f) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
              before    74.247k i/100ms
               after    77.990k i/100ms
Calculating -------------------------------------
              before    755.086k (± 1.2%) i/s    (1.32 μs/i) -      3.787M in   5.015504s
               after    802.630k (± 1.4%) i/s    (1.25 μs/i) -      4.055M in   5.053783s

Comparison:
               after:   802630.0 i/s
              before:   755086.0 i/s - 1.06x  slower
Calculating -------------------------------------
              before   800.000  memsize (   520.000  retained)
                        11.000  objects (     4.000  retained)
                         0.000  strings (     0.000  retained)
               after   760.000  memsize (   520.000  retained)
                        10.000  objects (     4.000  retained)
                         0.000  strings (     0.000  retained)

Comparison:
               after:        760 allocated
              before:        800 allocated - 1.05x more

Comment on lines -63 to +65
_scope{ array! value, *args }
_scope{ _array value, args }
Copy link
Author

Choose a reason for hiding this comment

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

json.set! :posts, posts, :id, :body
ruby 3.4.4 (2025-05-14 revision a38531fd3f) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
              before    69.118k i/100ms
               after    80.906k i/100ms
Calculating -------------------------------------
              before    718.268k (± 1.5%) i/s    (1.39 μs/i) -      3.594M in   5.005122s
               after    837.174k (± 1.5%) i/s    (1.19 μs/i) -      4.207M in   5.026501s

Comparison:
               after:   837173.8 i/s
              before:   718267.7 i/s - 1.17x  slower
Calculating -------------------------------------
              before   832.000  memsize (   520.000  retained)
                         8.000  objects (     4.000  retained)
                         0.000  strings (     0.000  retained)
               after   640.000  memsize (   520.000  retained)
                         7.000  objects (     4.000  retained)
                         0.000  strings (     0.000  retained)

Comparison:
               after:        640 allocated
              before:        832 allocated - 1.30x more

@moberegger moberegger marked this pull request as ready for review June 13, 2025 21:02
Copy link

@mscrivo mscrivo left a comment

Choose a reason for hiding this comment

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

I can also include benchmarks against upstream_main to show how all of our optimizations compare to stock jbuilder thus far.

Not necessary for this PR, but this would be really interesting to see!

Copy link

@Insomniak47 Insomniak47 left a comment

Choose a reason for hiding this comment

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

I like it! Also I'd love to see us start to build out a corpus of tests that's a bit more edge-casey so that we can see that we're not evaluating anything that's too trivial and losing out on more complex cases. All these look good AFAICT

@moberegger moberegger merged commit 46f9ef3 into main Jun 16, 2025
30 checks passed
This was referenced Jun 17, 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.

4 participants