Skip to content
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

Use to_json when rendering json response #181

Merged
merged 2 commits into from
Jan 12, 2025

Conversation

alexspeller
Copy link
Contributor

I was trying to debug why an inertia json response was 5x slower than the initial html response.

After some investigation, it turns out that render json: foo does not actually do the same thing as foo.to_json does. It does a whole lot of rails magic instead, which is both a lot slower and inconsistent.

Changing this to use page.to_json ensures consistency with the props output in inertia.html.erb, avoiding weird inconsistencies between props renders and full page renders, and is also a lot faster (especially if you are using a library like Oj to optimise json generation).

I was trying to debug why an inertia response was 5x slower than the initial page response.

After some investigation, it turns out that render json: foo does not actually do the same thing as foo.to_json does. It does a whole lot of rails magic instead, which is both a lot slower and inconsistent.

Changing this to use page.to_json ensures consistency with the props output in inertia.html.erb, avoiding weird inconsistencies between props renders and full page renders, and is also a lot faster (especially if you are using a library like Oj to optimise json generation in the first place).
@BrandonShar
Copy link
Collaborator

This is awesome, thanks @alexspeller !

Out of curiosity did you do any profiling or metrics for this? It would be cool to see the difference. No need to create something if you didn't.

@alexspeller
Copy link
Contributor Author

I made a controller that renders this data structure (from memory, so not including the time to generate it):

    data = (1..1_000_000).map {
      {
        a: rand,
        b: rand,
        c: rand,
        d: rand,
        h: {
          a: rand,
          b: rand,
          h: {
            a: rand,
            b: rand,
          },
        },
      }
    }

With Oj.optimize_rails

Initia page load rendering html: 2655ms
Inertia json render without to_json: 6150ms
Inertia json render with to_json: 2131ms

Without Oj.optimize_rails

Initia page load rendering html: 9634ms
Inertia json render without to_json: 14824ms
Inertia json render with to_json: 8752ms

I believe this gets worse with the complexity of the data structure, as render json: foo calls as_json recursively and also calls object.dup a bunch, so uses a lot more time and memory, whereas my understanding is that to_json calls to_json recursively.

@BrandonShar
Copy link
Collaborator

Wow, that's a big difference! I'm source diving a bit in Rails to understand this better and it looks like this mimics the logic here

Am I looking in the wrong place or is there something happening elsewhere first?

@bknoles
Copy link
Collaborator

bknoles commented Jan 8, 2025

Love this and love the helpful writeup! If I know @skryukov I suspect he did a backflip of joy when he saw "5x faster"!

Any reason not to merge this @BrandonShar ?

@BrandonShar
Copy link
Collaborator

@bknoles Only because I'm genuinely not clear on why it's working based on that link I posted above. It looks like the same logic is happening just in different places so I'm hoping to understand a bit more about what's happening here (or be told I'm looking in the wrong place in rails source, it would not be the first time 😄)

@bknoles
Copy link
Collaborator

bknoles commented Jan 8, 2025

ah ok, i didn't realize that was why you were source diving

@skryukov
Copy link
Contributor

skryukov commented Jan 9, 2025

Ok, so the problem lies in Oj's implementation and Rails internally calling to_json({}). See ohler55/oj#913

Here's a quick benchmark:

require 'active_support/all'
require 'oj'

require 'benchmark/ips'

Oj.optimize_rails

DATA = (1..100_000).map {
  {
    a: rand,
    b: rand,
    c: rand,
    d: rand,
    h: {
      a: rand,
      b: rand,
      h: {
        a: rand,
        b: rand,
      },
    },
  }
}

Benchmark.ips do |x|
  x.report('to_json') { DATA.to_json }
  x.report('to_json({})') { DATA.to_json({}) }
  x.compare!
end
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
             to_json     1.000 i/100ms
         to_json({})     1.000 i/100ms
Calculating -------------------------------------
             to_json      4.454 (± 0.0%) i/s  (224.49 ms/i) -     23.000 in   5.164198s
         to_json({})      2.097 (± 0.0%) i/s  (476.94 ms/i) -     11.000 in   5.256701s

Comparison:
             to_json:        4.5 i/s
         to_json({}):        2.1 i/s - 2.12x  slower

To be honest, I’m struggling with the idea of merging this. On one hand, it will speed things up for Oj users, but on the other hand, Inertia Rails has nothing to do with it.

@skryukov
Copy link
Contributor

skryukov commented Jan 9, 2025

On the other hand, the patch isn't obscure or anything, and patching our Renderer is not as easy. So, yeah, let's merge it.
@BrandonShar @bknoles

@alexspeller
Copy link
Contributor Author

alexspeller commented Jan 11, 2025

Hi, just coming back to this and that's a really good find, after looking at your link to the rails source I couldn't figure it out either and planned to spend some time digging today - thanks for figuring it out first 😄

Note that this still offers a good speedup regardless of Oj being present or not, when there are unknown keys that are present in the options provided to to_json like the rails render pipeline does:

Without Oj

require 'active_support/all'
require 'benchmark/ips'

DATA = (1..100_000).map {
  {
    a: rand,
    b: rand,
    c: rand,
    d: rand,
    h: {
      a: rand,
      b: rand,
      h: {
        a: rand,
        b: rand,
      },
    },
  }
}

if defined?(Oj)
  puts "Oj is loaded"
else
  puts "Oj is not loaded"
end

Benchmark.ips do |x|
  x.report('to_json') { DATA.to_json }
  x.report('to_json({})') { DATA.to_json({}) }
  x.report('to_json({prefixes: 123})') { DATA.to_json({prefixes: 123}) }
  x.compare!
end
Oj is not loaded
ruby 3.3.5 (2024-09-03 revision ef084cc8f4) [arm64-darwin24]
Warming up --------------------------------------
             to_json     1.000 i/100ms
         to_json({})     1.000 i/100ms
to_json({prefixes: 123})
                         1.000 i/100ms
Calculating -------------------------------------
             to_json      1.112 (± 0.0%) i/s  (899.26 ms/i) -      6.000 in   5.417173s
         to_json({})      1.068 (± 0.0%) i/s  (936.55 ms/i) -      6.000 in   5.629989s
to_json({prefixes: 123})
                          0.798 (± 0.0%) i/s     (1.25 s/i) -      4.000 in   5.020716s

Comparison:
             to_json:        1.1 i/s
         to_json({}):        1.1 i/s - 1.04x  slower
to_json({prefixes: 123}):        0.8 i/s - 1.39x  slower

With Oj but without calling Oj.optimize_rails

(exact same benchmark script but with require 'oj' at the top)

(almost exact same results as above)

With Oj and calling Oj.optimize_rails

(exact same benchmark script but with require 'oj' and Oj.optimise_rails at the top)

Oj is loaded
ruby 3.3.5 (2024-09-03 revision ef084cc8f4) [arm64-darwin24]
Warming up --------------------------------------
             to_json     1.000 i/100ms
         to_json({})     1.000 i/100ms
to_json({prefixes: 123})
                         1.000 i/100ms
Calculating -------------------------------------
             to_json      4.796 (± 0.0%) i/s  (208.53 ms/i) -     24.000 in   5.005977s
         to_json({})      2.041 (± 0.0%) i/s  (490.02 ms/i) -     11.000 in   5.392651s
to_json({prefixes: 123})
                          2.017 (± 0.0%) i/s  (495.70 ms/i) -     11.000 in   5.454995s

Comparison:
             to_json:        4.8 i/s
         to_json({}):        2.0 i/s - 2.35x  slower
to_json({prefixes: 123}):        2.0 i/s - 2.38x  slower

It's going to strongly depend on the amount of data and shape of the data, but I think that this should speed up the json render for all users not just Oj users, although the speedup with Oj is more dramatic

@skryukov
Copy link
Contributor

Hey, @alexspeller thanks for digging into this!

Note that this still offers a good speedup regardless of Oj being present or not

Not quite. The difference in your benchmark comes from the fact that DATA.to_json({}) creates a new hash every iteration. Rails does that in any case, so a more proper benchmark would be something like this:

Benchmark.ips do |x|
  x.report('to_json') { {}; DATA.to_json }
  x.report('to_json({})') { DATA.to_json({}) }
  x.compare!
end

which ends up with similar results:

ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [arm64-darwin24]
Warming up --------------------------------------
             to_json     1.000 i/100ms
         to_json({})     1.000 i/100ms
Calculating -------------------------------------
             to_json      1.112 (± 0.0%) i/s  (899.27 ms/i) -      6.000 in   5.396313s
         to_json({})      1.115 (± 0.0%) i/s  (896.66 ms/i) -      6.000 in   5.381196s

Comparison:
         to_json({}):        1.1 i/s
             to_json:        1.1 i/s - 1.00x  slower

I still think there are a lot of Oj users out there, so the PR is good to go. Thanks again @alexspeller!

@alexspeller
Copy link
Contributor Author

The empty hash case is the same as calling without a hash - try this one:

Benchmark.ips do |x|
  x.report("to_json") { { prefixes: 123 }; DATA.to_json }
  x.report("to_json({prefixes: 123})") { DATA.to_json({ prefixes: 123 }) }
  x.compare!
end
Oj is not loaded
ruby 3.3.5 (2024-09-03 revision ef084cc8f4) [arm64-darwin24]
Warming up --------------------------------------
             to_json     1.000 i/100ms
to_json({prefixes: 123})
                         1.000 i/100ms
Calculating -------------------------------------
             to_json      0.989 (± 0.0%) i/s     (1.01 s/i) -      5.000 in   5.060382s
to_json({prefixes: 123})
                          0.793 (± 0.0%) i/s     (1.26 s/i) -      4.000 in   5.045480s

Comparison:
             to_json:        1.0 i/s
to_json({prefixes: 123}):        0.8 i/s - 1.25x  slower

@skryukov
Copy link
Contributor

The empty hash case is the same as calling without a hash - try this one:

Inertia only sends status and content_type options which are removed by Rails, so the only case we should evaluate against is an empty hash 😄

@bknoles
Copy link
Collaborator

bknoles commented Jan 12, 2025

Love all this back and forth. I'm mentally bookmarking this for the future when I want to explain why "LOC committed" is not a good measurement of engineering effort/productivity.

I like to summarize discussions like this mostly because re-explaining something ensures I actually grok it.

Explicitly calling .to_json on an object sent to the Rails json renderer, a la: render json: the_object_in_question avoids a slowdown caused by the way that Oj and Rails interact. That condition would also improve non-Oj driven json renders which pass a non-empty options hash to .to_json. However, the InertiaRails renderer only sends an empty options hash to the underlying .to_json call. So, this fix will only help Oj users.

I agree with @skryukov that this is a nice change worth merging even if Oj/Rails quirks could be considered out of our scope. There's no real downside. So, I'm smashing merge! Thanks all for the detective work!

@bknoles bknoles merged commit 0cc75d7 into inertiajs:master Jan 12, 2025
17 checks passed
@alexspeller alexspeller deleted the patch-1 branch January 14, 2025 21:52
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