Skip to content

Optimize for server requests/responses #312

Closed

Description

@jkeiser

Thoughts for discussion, and a few suggestions:

A large % of json parsed in this world is parsed as server requests and responses. There are a few relevant characteristics here:

  • Throughput of a single document still matters, but bulk throughput is the bottleneck for such a server--specifically, how many documents you can parse at a time.
  • POST requests (the main place you do JSON, though sometimes it gets encoded into a query parameter in the URL) tend to be very small (in many cases {}, but often something like { "q": "name = 'john'" }). In fact, if you could measure every valid JSON document ever, you'd find a very significant percentage are the strings {} and { }.
  • Responses from a given endpoint (not all) tend to be very similar sizes because an endpoint tends to have a single record schema (which often have similar length) and the endpoint type dictates a lot about the response as well.
    • POST endpoints designed to do an operation, for example, will usually return short responses: {} or { "success": "true" }.
    • GET endpoints designed to return a single record will often return small or small-ish responses like { "id": 1, "name": "john" }--particularly, endpoints like this that return < 64 bytes are likely to do so frequently.
    • GET endpoints designed to return a list of records are much harder to predict, but many will be small, for two reasons: first, many lists are just empty :) And second, the entries in a lists generally don't return the whole record and tend to be small, so a surprising number of nonempty lists will fit in under 64 bytes: [ { "id": 1 }, { "id": 10 }, { "id": 20 } ] for example.
  • Depending on the country you are in, endpoints returning user-facing strings and communication will return non-ASCII UTF-8 on nearly every call. However, even in those countries, these endpoints tend to live alongside other endpoints that only ever return ASCII (for computer to computer communication). In these countries, branch misprediction of UTF-8 in a server will probably be pretty high. And since most of the documents are not going to be huge (a single message, a single customer ...) you don't always get the benefit of the fact that a single document will probably have UTF-8 in nearly every chunk, or nowhere.

All this is to say, we might do well to think about the effects of misprediction in a typical server architecture, where a thread pool is used to handle all requests and threads have no affinity to a given endpoint or JSON profile. In these situations, where there are many different endpoints. My thoughts:

  • Can we get simdjson to match other JSON parsers when parsing loads and loads of small (< 64 byte) documents?
  • Can we reduce misprediction for users who know which of their endpoints have mostly small documents and which have lots of UTF-8? For example, a separate copy of the function for each situation, which the user calls and the processor does branch prediction differently each time? Would the instruction cache effect overwhelm the benefit of fewer mispredictions?
  • Much more "pie in the sky" crazy mode: for super high load situations on endpoints with lots of small documents: I'm curious if simdjson could parse multiple documents in bulk, benefitting from a "batch mode" where multiple documents are crammed together in a single queue. The user could have a small parsing thread pool and cram documents together, asking simdjson to parse them in bulk whenever the buffer reaches 64 bytes or a timeout occurs. Latency increases but overall throughput might be improved.

Activity

  1. lemire commented on Sep 13, 2019

    @lemire
    Member

    Can we get simdjson to match other JSON parsers when parsing loads and loads of small (< 64 byte) documents?

    Handling tiny documents very fast is certainly possible and fun. However, it is almost an entirely different problem. That is not to say that we shouldn't or couldn't do it... but it almost certainly best handled with a different/new code base.

    Would you open this as a new issue?

    Can we reduce misprediction for users who know which of their endpoints have mostly small documents and which have lots of UTF-8? For example, a separate copy of the function for each situation, which the user calls and the processor does branch prediction differently each time?

    UTF-8 validation is a small part of our parsing as things stand now. Have you tried running benchmarks?

    Much more "pie in the sky" crazy mode: for super high load situations on endpoints with lots of small documents: I'm curious if simdjson could parse multiple documents in bulk, benefitting from a "batch mode" where multiple documents are crammed together in a single queue. The user could have a small parsing thread pool and cram documents together, asking simdjson to parse them in bulk whenever the buffer reaches 64 bytes or a timeout occurs. Latency increases but overall throughput might be improved.

    This is very similar to an existing issue, see #188

  2. jkeiser commented on Oct 6, 2019

    @jkeiser
    MemberAuthor

    Handling tiny documents very fast is certainly possible and fun. However, it is almost an entirely different problem. That is not to say that we shouldn't or couldn't do it... but it almost certainly best handled with a different/new code base.

    I guess I'm interested in having a valid choice of the fastest JSON parser overall, because I see in simdjson an actual chance to fight global warming :) Bulk processing a la the ndjson thing might help a ton with server throughput, even if it reduces average latency by a little bit. Either way, we should at least have benchmarks and find out exactly what.

    Would you open this as a new issue?

    Will do!

    UTF-8 validation is a small part of our parsing as things stand now. Have you tried running benchmarks?

    @lemire Unless I'm way off in my benchmarks, the UTF-8 check nearly doubles stage 1 iteration time (adds about 75%), even without misprediction.

    Source: ./parse -tf baseline-structurals-2.json and ./parse -tf baseline-utf-8.json, both 640KB files with 2 structurals per block, one of which has UTF-8 in every block.

    (I don't have a good measure of overall impact because one file has numbers and one has strings, which stage 2 treats differently. I'll fix that when I have a moment.)

  3. lemire commented on Oct 6, 2019

    @lemire
    Member

    I’m game to pursue this.

    There are things we know regarding the processing of small batches... the overhead (e.g. the memory allocation, function calls) that is negligible when the data has a non-trivial size end up dominating the running time.

    Also the metrics need to change... instead of GB/s, you want to move toward measures like documents per second...

    I’ll check your UTF-8 benchmark.

  4. lemire commented on Oct 7, 2019

    @lemire
    Member

    Back to UTF-8...

    You are correct that in the example you refer to (baseline-utf-8.json), utf-8 validation uses half of the running time. I invite you to contribute at least some of these synthetic cases to master so we can include them in our ongoing performance tests.

    I don't think I ever saw such a relatively high UTF-8 cost in a JSON file.

    Stage 1, in your example, on one test machine goes from 3.4 GB/s to 6.4 GB/s if I disable UTF-8 validation. That means that UTF-8 validation runs at 3.4 GB/s in this case if my math is correct.

    For the next simdjson version, I'd like to move UTF-8 validation to the string tape. Doing so in this case should drastically lower the cost since only a small fraction of the input is actually string content. The idea would be to only run UTF-8 validation on string content.

    This means that we should be able to get a nice speed bump for baseline-utf-8.json.

    Hence I reiterate that it would be interesting to have it as part of our core benchmarks.

  5. jkeiser commented on Oct 8, 2019

    @jkeiser
    MemberAuthor

    Yep, those are part of a set of files including misprediction cost measurement, as well as feature based performance reporting to help figure it what tradeoffs you are making. It needs a few updates before I'm ready to make it a pr, though.

  6. jkeiser commented on Oct 8, 2019

    @jkeiser
    MemberAuthor

    That means that UTF-8 validation runs at 3.4 GHz in this case if my math is correct.

    @lemire note that this is in-context perf impact of UTF-8, which can differ in either direction from the actual impact. If UTF-8 validation causes a long pole to get longer and stop soaking up CPU with parallel instructions, the impact of UTF-8 validation could be higher than its actual cost. If it causes a higher instructions-per-cycle with parallel instructions, then the actual cost will be lower.

    At any rate, I think you're 100% right about moving it to the string tape. It's going to be the highest-density place to run it, bar none.

  7. lemire commented on Oct 8, 2019

    @lemire
    Member

    (I meant 3.4GB/s.)

    You are correct that it is not independent tasks.

    I am thinking that we might want to store keys differently; keys are typically ASCII.

  8. jkeiser commented on Oct 8, 2019

    @jkeiser
    MemberAuthor

    Yeah. Also, keys tend to have VERY high duplication rate across a single file, and are often looked up by name, meaning hashing and dedup might actually buy something.

    Even more, because they are typically unescaped, you could dedup before you unescape or write to the string tape, and because they are ASCII, you wouldn't have to worry about Unicode equality canonicalization--just a straight hash of the bytes would be enough for comparison (sans hash collisions, which I don't know what to think about). Keys that had non-normalized Unicode or backslashes wouldn't get deduped, but it might be win anyway.

    Heh, getting off topic. As far as optimizing for server requests/responses, I am hoping that SIMD and branch-free techniques will make us faster for documents >= 64 bytes. I think I can live with being slower or at least not faster on < 64 bytes, as long as it's not a huge multiplier. Regardless, we should measure how our processing time for smaller documents compares the same way we do with big ones (and at what point it gets better) just so people can make informed decisions on whether simdjson fits their production load or whether they need a hybrid.

  9. lemire commented on Oct 8, 2019

    @lemire
    Member

    I think that a good first step would be to write a benchmark. Simulate a stream of server responses.

  10. jkeiser commented on Jan 10, 2020

    @jkeiser
    MemberAuthor

    For this purpose, I think "parse a generically representative spread of JSON responses," for example, 10% 16-byte, 50% 256-byte, 25% 1K, 10% 1M, 5% 10M, is a reasonable benchmark. Percentages may vary, I'm anecdotizing based on servers I've built or used. 16-byte is empty requests/responses. 256-byte are small single objects and lists. 1K is large single objects and reasonably-sized lists. 1M-10M is Just About Everything Else.

    Parsing JSON requests will be a different proportion, but it's a good start.

  11. jkeiser commented on Mar 10, 2020

    @jkeiser
    MemberAuthor

    It looks to me like we are already faster than the competition for pretty much all file sizes. Running parsingcompetition on all files in jsonexamples (with enough iterations to get a low-ish error margin):

    Host File Size getline simdjson (dynamic mem) simdjson RapidJSON RapidJSON (insitu) sajson (dynamic mem) sajson nlohmann-json memcpy
    Kady Lake R small/flatadversarial.json 64 B 0.034 GB/s 0.034 GB/s 0.038 GB/s 0.036 GB/s 0.036 GB/s 0.036 GB/s 0.040 GB/s 0.023 GB/s 0.043 GB/s
    Kady Lake R small/adversarial.json 80 B 0.038 GB/s 0.042 GB/s 0.047 GB/s 0.044 GB/s 0.044 GB/s 0.044 GB/s 0.050 GB/s 0.031 GB/s 0.050 GB/s
    Kady Lake R small/demo.json 387 B 0.176 GB/s 0.176 GB/s 0.204 GB/s 0.161 GB/s 0.176 GB/s 0.168 GB/s 0.184 GB/s 0.072 GB/s 0.215 GB/s
    Kady Lake R small/smalldemo.json 204 B 0.102 GB/s 0.097 GB/s 0.113 GB/s 0.097 GB/s 0.102 GB/s 0.097 GB/s 0.107 GB/s 0.044 GB/s 0.128 GB/s
    Kady Lake R small/repeat.json 11 KB 1.276 GB/s 1.646 GB/s 1.695 GB/s 0.533 GB/s 0.638 GB/s 0.714 GB/s 0.829 GB/s 0.115 GB/s 5.977 GB/s
    Kady Lake R small/truenull.json 11 KB 5.217 GB/s 1.071 GB/s 1.091 GB/s 0.563 GB/s 0.558 GB/s 0.706 GB/s 0.930 GB/s 0.127 GB/s 6.316 GB/s
    Kady Lake R small/twitter_timeline.json 41 KB 10.829 GB/s 1.427 GB/s 1.461 GB/s 0.362 GB/s 0.542 GB/s 0.652 GB/s 0.771 GB/s 0.074 GB/s 14.563 GB/s
    Kady Lake R github_events.json 63 KB 1.687 GB/s 2.178 GB/s 2.215 GB/s 0.419 GB/s 0.647 GB/s 0.818 GB/s 0.919 GB/s 0.090 GB/s 19.156 GB/s
    Kady Lake R apache_builds.json 124 KB 1.595 GB/s 2.657 GB/s 2.657 GB/s 0.395 GB/s 0.533 GB/s 1.059 GB/s 1.260 GB/s 0.104 GB/s 24.014 GB/s
    Kady Lake R numbers.json 146 KB 17.256 GB/s 1.003 GB/s 0.979 GB/s 0.520 GB/s 0.524 GB/s 0.469 GB/s 0.573 GB/s 0.056 GB/s 26.338 GB/s
    Kady Lake R instruments.json 215 KB 1.204 GB/s 1.885 GB/s 1.926 GB/s 0.528 GB/s 0.644 GB/s 0.717 GB/s 0.932 GB/s 0.095 GB/s 26.871 GB/s
    Kady Lake R random.json 498 KB 0.857 GB/s 1.388 GB/s 1.394 GB/s 0.352 GB/s 0.482 GB/s 0.577 GB/s 0.750 GB/s 0.066 GB/s 26.178 GB/s
    Kady Lake R update-center.json 520 KB 16.355 GB/s 1.745 GB/s 1.713 GB/s 0.351 GB/s 0.538 GB/s 0.619 GB/s 0.765 GB/s 0.078 GB/s 26.009 GB/s
    Kady Lake R twitterescaped.json 549 KB 16.445 GB/s 1.060 GB/s 1.040 GB/s 0.363 GB/s 0.561 GB/s 0.652 GB/s 0.742 GB/s 0.081 GB/s 27.039 GB/s
    Kady Lake R twitter.json 616 KB 1.583 GB/s 2.137 GB/s 2.097 GB/s 0.420 GB/s 0.625 GB/s 0.776 GB/s 0.933 GB/s 0.091 GB/s 25.988 GB/s
    Kady Lake R mesh.json 706 KB 17.394 GB/s 0.893 GB/s 0.846 GB/s 0.481 GB/s 0.478 GB/s 0.401 GB/s 0.525 GB/s 0.060 GB/s 26.603 GB/s
    Kady Lake R mesh.pretty.json 1.6 MB 1.032 GB/s 1.455 GB/s 1.415 GB/s 0.719 GB/s 0.717 GB/s 0.655 GB/s 0.827 GB/s 0.101 GB/s 28.679 GB/s
    Kady Lake R citm_catalog.json 1.7 MB 1.642 GB/s 2.524 GB/s 2.527 GB/s 0.772 GB/s 0.828 GB/s 0.899 GB/s 1.106 GB/s 0.131 GB/s 28.361 GB/s
    Kady Lake R canada.json 2.2 MB 18.650 GB/s 1.061 GB/s 1.007 GB/s 0.537 GB/s 0.534 GB/s 0.404 GB/s 0.530 GB/s 0.050 GB/s 27.385 GB/s
    Kady Lake R marine_ik.json 2.9M MB 2.656 GB/s 0.827 GB/s 0.830 GB/s 0.451 GB/s 0.451 GB/s 0.414 GB/s 0.591 GB/s 0.059 GB/s 25.609 GB/s
    Kady Lake R gsoc-2018.json 3.2M MB 5.023 GB/s 3.090 GB/s 3.067 GB/s 0.485 GB/s 0.867 GB/s 1.033 GB/s 1.095 GB/s 0.112 GB/s 23.223 GB/s

    We should make sure and make that point when marketing: it's not just large files.

  12. jkeiser commented on Mar 10, 2020

    @jkeiser
    MemberAuthor

    A graph:

    image

  13. lemire commented on Mar 10, 2020

    @lemire
    Member

    That's great news. I can't believe I did not run these benchmarks for myself some time ago.

    Of course, we want memory allocation to be done ahead of time... otherwise we probably suffer. So if you are going to eat just one tiny JSON, we are probably not super competitive. Of course, if you are just going to eat one tiny JSON, parsing performance is probably not high on your list of priorities.

    Thanks for documenting this... we probably should update the README accordingly.

    Do we close this issue, now?

  14. jkeiser commented on Mar 10, 2020

    @jkeiser
    MemberAuthor

    I think so. Streaming parsing would make it easier too, but this is the big blocker.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions