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

Performance: Avoid converting responses to serde_json::Value #639

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

Conversation

ckamm
Copy link

@ckamm ckamm commented Sep 27, 2021

Previously, all responses were converted to serde_json::Value before being serialized into the response string. Since jsonrpc does not inspect the result object in any way, this step could be skipped.

Now, the response string is built from the result objects much earlier and the Value step is skipped.

This patch has large performance benefits for huge responses: In tests with 4.5 GB responses, the jsonrpc serialization overhead after returning from an rpc function was reduced by around 35%. Which means several seconds of speed-up in response time.

To accomplish this while mostly retaining API compatibility, the traits RpcMethod, RpcMethodSync, RpcMethodSimple are now generic in their return type and are wrapped when added to an io handler.

There's now a distinction between the parsed representation of jsonrpc responses (Output/Response) and the result of rpc function calls (WrapOutput/WrapResponse) to allow the latter to carry the pre-serialized strings.

Review notes:

  • I'm not happy with the WrapOutput / WrapResponse names, glad for suggestions.
  • Similarly, rpc_wrap must be renamed and moved.
  • The http handler health request is now awkward, and must extract the result/error from the already-serialized response. Probably worth it.
  • The largest API breakage I could see is in the middleware, where the futures now return WrapOutput/WrapResult instead of Output/Result.
  • One could make WrapOutput just be String, but having a separate type is likely easier to read.

See #212

core/src/io.rs Outdated Show resolved Hide resolved
@maciejhirsz
Copy link
Contributor

You could avoid all the generics with Box<RawValue> using the to_raw_value function in place of the Value. Box<RawValue> is just a Box<str> that's guaranteed to be valid JSON, which serde_json copies as-is to whatever Serialize-able type you put it in. Compared to passing the result of the output with generics there is going to be extra allocation and memcpy (to put the JSON string of the result into the JSON string of the whole response), but that overhead might still be insignificant in the grand scheme of things.

@ckamm
Copy link
Author

ckamm commented Sep 29, 2021

You could avoid all the generics with Box<RawValue> using the to_raw_value function in place of the Value. Box<RawValue> is just a Box<str> that's guaranteed to be valid JSON, which serde_json copies as-is to whatever Serialize-able type you put it in. Compared to passing the result of the output with generics there is going to be extra allocation and memcpy (to put the JSON string of the result into the JSON string of the whole response), but that overhead might still be insignificant in the grand scheme of things.

I had a similar idea initially, but my benchmarks showed that using RawValue instead of Value as an intermediate didn't actually save much time:

to_value+to_string: total 956ms, to_value 344ms, to_string 612ms
to_rawvalue+to_string: total 943ms, to_rawvalue 594ms, to_string 349ms
to_string: total 593ms
clone: total 338ms

This was for a 1GB result. clone is just calling String::clone on a string of that length - suggesting that this may be memory bandwidth (or more likely memory management) constrained.

@maciejhirsz
Copy link
Contributor

Yeah, that would be 3GB/s for a memcpy, which sounds about right for a payload of this size on a single thread. I would have expected to_value to be more expensive (and the whole optimization to be way more than 35% cut) as it converts all structs to HashMaps, but without seeing the data you are benchmarking on it's hard to tell what's happening.

@ckamm
Copy link
Author

ckamm commented Sep 29, 2021

Yeah, it's a very simple benchmark - can share it if you're interested. In the wild this has improved time-to-first-byte from around 26s to 19s for 4.5 GB responses. The time not spent in the actual rpc handler function has gone down from 14s to 8s. (of course having responses of that size it a problem in itself)

@ckamm
Copy link
Author

ckamm commented Oct 7, 2021

What would need to happen to give the patch - or a variant of it - the best chances of making it into the next release that allows api changes like this?

@ckamm
Copy link
Author

ckamm commented Oct 23, 2021

@tomusdrw Do you have thoughts on this?

If this breaks API too much and has no chance of making it in, feel free to say so. I've since seen that https://github.com/paritytech/jsonrpsee does already not have the same overhead.

Previously, all responses were converted to serde_json::Value before
being serialized to string. Since jsonrpc does not inspect the result
object in any way, this step could be skipped.

Now, result objects are serialized to string much earlier and the Value
step is skipped.

This patch has large performance benefits for huge responses: In tests
with 4.5 GB responses, the jsonrpc serialization overhead after
returning from an rpc function was reduced by around 35%. Which means
several seconds of speed-up in response times.

To accomplish this while mostly retaining API compatibility, the traits
RpcMethod, RpcMethodSync, RpcMethodSimple are now generic in their
return type and are wrapped when added to an io handler.

There's now a distinction between the parsed representation of jsonrpc
responses (Output/Response) and result of rpc functions calls
(WrapOutput/WrapResponse) to allow the latter to carry the
pre-serialized strings.

Review notes:
- I'm not happy with the WrapOutput / WrapResponse names, glad for
  suggestions.
- Similarly, rpc_wrap must be renamed and moved.
- The http handler health request is now awkward, and must extract the
  result/error from the already-serialized response. Probably worth it.
- The largest API breakage I could see is in the middleware, where the
  futures now return WrapOutput/WrapResult instead of Output/Result.
- One could make WrapOutput just be String, but having a separate type
  is likely easier to read.

See paritytech#212
ckamm added a commit to ckamm/solana that referenced this pull request Oct 25, 2021
ckamm added a commit to ckamm/solana that referenced this pull request Oct 25, 2021
ckamm added a commit to ckamm/solana that referenced this pull request Oct 25, 2021
ckamm added a commit to ckamm/solana that referenced this pull request Oct 25, 2021
ckamm added a commit to ckamm/solana that referenced this pull request Oct 28, 2021
Copy link
Contributor

@tomusdrw tomusdrw left a comment

Choose a reason for hiding this comment

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

lgtm! Will probably need a major version bump.

core/src/types/response.rs Outdated Show resolved Hide resolved
core/src/types/response.rs Outdated Show resolved Hide resolved
core/src/calls.rs Outdated Show resolved Hide resolved
- WrapOutput -> SerializedOutput
- WrapResponse -> SerializedResponse
- RpcMethodWrapped -> RpcMethodWithSerializedOutput
@ckamm
Copy link
Author

ckamm commented Nov 1, 2021

I've done the renames.

Do you have an idea for what to do about rpc_wrap(). Should I implement Into/From for the RpcMethod -> RpcMethodWithSerializedOutput conversion, or keep it explicit?

Copy link
Contributor

@tomusdrw tomusdrw left a comment

Choose a reason for hiding this comment

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

lgtm!

core/src/calls.rs Outdated Show resolved Hide resolved
core/src/calls.rs Outdated Show resolved Hide resolved
core/src/calls.rs Outdated Show resolved Hide resolved
core/src/calls.rs Show resolved Hide resolved
ckamm and others added 2 commits November 4, 2021 07:11
Co-authored-by: Tomasz Drwięga <tomusdrw@users.noreply.github.com>
Co-authored-by: Tomasz Drwięga <tomusdrw@users.noreply.github.com>
ckamm added a commit to ckamm/solana that referenced this pull request Nov 17, 2021
ckamm added a commit to ckamm/solana that referenced this pull request Nov 30, 2021
ckamm added a commit to ckamm/solana that referenced this pull request Dec 20, 2021
ckamm added a commit to ckamm/solana that referenced this pull request Dec 21, 2021
ckamm added a commit to ckamm/solana that referenced this pull request Jan 12, 2022
ckamm added a commit to ckamm/solana that referenced this pull request Jan 26, 2022
ckamm added a commit to ckamm/solana that referenced this pull request Feb 28, 2022
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.

None yet

3 participants