Click here to Skip to main content
15,887,376 members
Please Sign up or sign in to vote.
0.00/5 (No votes)
See more:
I had a take-home task. One of the aspects of the task was to create a fast json parser of coinbase feed. The parser should extract 3 json fields: sequence number, ask price and bid price.

I managed to achieve ≈39ns median time (reported by google benchmark), which is as good as the best (single) L3-cache reference time, but apparently it was a considered as a fail. This was their feedback:

>... area of concern was the JSON parser; the search repetitions and the expense of conversions in methods like `toDouble()` could be optimized.

Can anyone tell me what is wrong with the approach below?

What I have tried:

Search

First of all, we have a bunch of json like this:

{"type":"ticker","sequence":952144829,"product_id":"BTC-USD","price":"17700","open_24h":"5102.64","volume_24h":"146.28196573","low_24h":"4733.36","high_24h":"1000000","volume_30d":"874209.06385166","best_bid":"17700.00","best_bid_size":"96.87946051","best_ask":"17840.24","best_ask_size":"0.00010000","side":"sell","time":"2023-06-09T22:13:08.331784Z","trade_id":65975402,"last_size":"0.0001"}


According to the task, we need to extract only these fields:

* SEQUENCE = "nce""; // "sequence"
* BID = "bid""; // "best\_bid"
* ASK = "ask""; // "best\_ask"

First observation: the position of "sequence" does not change (much) from one json to another. It means we do not need to look for the key from the beginning of the string. Instead I remember the position where the key was found last time, and next time, I start looking for the key from this position.

If I cannot find it at this position, I start looking at pos-1 (1 character to the left), pos+1 (1 character to the right), pos-2, pos+2, etc...

Second observation is that I can use the hash from "rolling hash" search approach. I also need only 4 characters to distinguish and identify necessary keys:

* "nce"" for "sequence"
* "bid"" for "best_bid"
* "ask"" for "best_ask"

So, "to find a key" just means, precalculate an integer:
C++
(str[pos] << 0) + (str[pos+1] << 5) + (str[pos+2] << 10) + (str[pos+3] << 15)
for the needle, calculate integer for certain position in the string and compare two integers.

toDouble() conversion

Pretty straightforward:

* get the number in `result` until we meet `.` or end of string.
* if there is `.`, continue with the `result`, but also calculate factor (as a power of 10), which we will then use to divide:



C++
static Float toDouble(std::string_view str, StrPos start) {
    int64_t result = 0;
    int64_t factor = 1;
         
    for(; start != str.size() && str[start] >= '0' && str[start] <= '9'; ++start)
       result = result * 10 + (str[start] - '0');
         
    if(start != str.size() && str[start] == '.') [[likely]] {
       ++start;
       for(; start != str.size() && str[start] >= '0' && str[start] <= '9'; ++start) {
          result = result * 10 + (str[start] - '0');
          factor *= 10;
       }
    }
    return (Float)result / (Float)factor;
 }


Full code is here
Posted
Updated 24-Jun-23 10:39am

1 solution

I would make the following suggestions:

1. instead of doing the conversion with your own function, check if an existing conversion function,
such as std::stof or std::stod from the C++ standard library would not be more performant.

2. the number representation could be changed from int64_t to int32_t for more efficient memory usage.
Using a smaller number representation limits the range of values. Would have to be checked what is needed.

3. code repetitions can be avoided if e.g. a lambda function abstracts loop logic for processing the digits.

4. instead of repeated calculation of result and factor within a loop it is sufficient to update
result only once. A separate buffer would be possible for processing the fractionalResult and
fractionalFactor.

5. it would be useful to perform a check for invalid characters to catch errors.
 
Share this answer
 
Comments
Bruno van Dooren 25-Jun-23 7:07am    
I agree with most of your comments, except the one about memory.
This is an assignment for a HFT firm. Algorithm speed is everything to those people. Anything else is a secondary concern if at all. Int64 is the native integer length and can be done in a single instruction. It could be that Int32 is equally fast but I would benchmark them both and go with the fastest one because that's what they are after.
merano99 25-Jun-23 7:37am    
Thanks for the comment. The memory interface is now wide enough to handle 64-bit in parallel, I had that in mind as well.
I listed it under the desired aspect "effort for conversions" anyway. Whether or how this affects performance would have to be investigated separately and would probably also be system-dependent. To get along with less cache could perhaps have a positive effect.

"Int64 is the native integer length and can be done in a single instruction."
Sure, but that says nothing about performance. We agree that you have to put work into it to make comparisons and optimize the algorithm. With C and C++ the native integer length remained on the systems known to me with 32Bit. But that can change quickly and is also system dependent.

This content, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)



CodeProject, 20 Bay Street, 11th Floor Toronto, Ontario, Canada M5J 2N8 +1 (416) 849-8900