GH-50944: [C++] Replace RapidJSON with simdjson in JSON chunker - #50945
GH-50944: [C++] Replace RapidJSON with simdjson in JSON chunker#50945Reranko05 wants to merge 23 commits into
Conversation
e9fc9fe to
be4c2a1
Compare
pitrou
left a comment
There was a problem hiding this comment.
I don't understand why this is parsing JSON by hand?
It seems that we might be able to use simdjson::ondemand::parser::iterate_many.
I initially tried using That said, I agree that parsing JSON manually here is not ideal. I'll revisit this using |
|
@pitrou I tried using Manual structural parsing approach preserved the existing test behavior. @rok, since your earlier implementation was helpful here, do you have any suggestions on how to preserve the current error behavior with |
As long as an error is reported while reading the JSON stream, I don't think we care if it's reported by the chunker or the parser. |
|
@pitrou I gave |
Hmm, I see. Thanks for trying anyway :-)
Well, as a last resort, yes. The problem:
|
|
Try to understand the issue. Is it that json strings legal for rapidjson may fail on simdjson, makes future Arrow release potentially incompatible to old version? Writing our own optimized version looks not ideal. Can we just use simdjson? It's state-of-the-art, and even with self written object delimiter, there's still incompatibility risk I'm afraid. |
|
@cyb70289 I don't think the issue is JSON compatibility between RapidJSON and simdjson. The main issue I ran into is the boundary/streaming semantics. The previous RapidJSON implementation uses With I agree that writing our own optimized JSON parser would not be ideal. The manual approach I used, and which @rok also implemented in rok#47, only scans for the boundary of the first complete object or array while respecting strings and escapes, and then lets simdjson perform the actual JSON validation. But I agree this still introduces complexity and needs careful testing. If there is a way to use simdjson directly while preserving the old stop-after-one-value semantics, that would definitely be preferable. |
Is that a problem? We want to keep compatibility when parsing valid JSON streams. The failure mode for an invalid JSON stream can change. |
|
A discussion about ignoring trailing garbage in simdjson. Looks there're real use cases lenient parsing can be useful. |
|
Trailing garbage is not the problem here. We are parsing a stream of valid JSON documents. We are happy to error out on trailing garbage. |
6923f56 to
008f53c
Compare
008f53c to
1f75592
Compare
1f75592 to
8b364d1
Compare
|
I've made enough changes that it may benefit from another person's review. @HuaHuaY Do you want to take a look? |
|
@github-actions crossbow submit -g cpp |
|
Revision: 386900c Submitted crossbow builds: ursacomputing/crossbow @ actions-810cb09b40 |
|
(CI failures are unrelated) |
|
Thanks, @pitrou. Appreciate the additional changes and review. |
|
@HuaHuaY Could you review this when you have time? Thanks! |
|
@taepper Would you like to take a look at this PR? |
| if (consumed_length > 0) { | ||
| // If we found at least one document, also consume its trailing whitespace | ||
| // to avoid stray bytes at the end of the stream. | ||
| consumed_length += ConsumeWhitespace(input.substr(consumed_length)); | ||
| } |
There was a problem hiding this comment.
Why do we care about this? We can just emit boundaries with whitespace, it is valid json
There was a problem hiding this comment.
Or is the contract that consumed_length is the same as input.size() if we consume the last element of the input?
There was a problem hiding this comment.
Or is the contract that
consumed_lengthis the same asinput.size()if we consume the last element of the input?
I'm not sure it's explicitly spelled out, but that would have my preference.
If I disable this code I get some test errors, though they might be harmless.
| // XXX Should be pass a specific batch_size? | ||
| // The default value used by simdjson is 1MB, probably enough for most purposes. |
There was a problem hiding this comment.
I do not think we care about custom batch sizing
There was a problem hiding this comment.
We would care in case a single JSON object is larger than the configured simdjson batch size (1MB), because it would fail parsing.
But such cases are probably rare enough that we can defer handling them to when someone reports a bug.
There was a problem hiding this comment.
Ah, I misunderstood the comment. I thought this was about batch sizing for internal simdjson buffers (i.e. only for optimization purposes) and missed that this was a size limit for individual json documents
There was a problem hiding this comment.
We could use batch_size equal to input.size(), if we do not want to fail in this case:
const size_t batch_size = std::max<size_t>(input.size(), 1);
RETURN_NOT_OK(ToStatus(parser_.iterate_many(input, batch_size).get(stream)));
But I agree that it could be acceptable, given that the chunker for newline-delimited data does not have this limit
| buffer_.reserve(partial.size() + block.size() + simdjson::SIMDJSON_PADDING); | ||
| buffer_.append(partial); | ||
| buffer_.append(block); |
There was a problem hiding this comment.
Ugh, simdjson does not have RapidJson's MultiStringStream so the current api is a rough fit. I see that call sites want this "partial", "continuation" split to consume streams efficiently
We might want to at least check for emptiness of partial. After checking call sites this seems to be the case a few times
There was a problem hiding this comment.
partial being empty doesn't change the fact that we need to copy block to ensure there's enough padding.
We can rework this later by having the BoundaryFinder API take Buffer arguments instead of std::string_view, because that would let us inspect their capacity and avoid copying if the Buffer has enough padding already.
That said, chunking is already faster with this PR than it used to be with RapidJSON:
- before:
ChunkJSONPrettyPrinted 755476 ns 755351 ns 932 bytes_per_second=276.193Mi/s json_size=218.757k
ChunkJSONLineDelimited 94.6 ns 94.6 ns 7391992 bytes_per_second=0/s json_size=193.757k
- after:
ChunkJSONPrettyPrinted 634472 ns 634465 ns 1110 bytes_per_second=388.942Mi/s json_size=258.757k
ChunkJSONLineDelimited 94.5 ns 94.5 ns 7418649 bytes_per_second=0/s json_size=193.757k
(on Ubuntu 24.04 with a AMD Zen 2 CPU supporting AVX2)
There was a problem hiding this comment.
I see. I agree that we will not be able to avoid at least one copy until the APIs evolve
| // Consume the first or last JSON object (depending on `until_end`) | ||
| // and return the consumed JSON byte length, or 0 if no valid document | ||
| // can be parsed. | ||
| Result<size_t> ConsumeWholeObject(simdjson::padded_string_view input, bool until_end) { |
There was a problem hiding this comment.
I find the name until_end a little confusing
There was a problem hiding this comment.
I thought that would repeat more code, such as the document stream setup and the handling of the various corner cases.
There was a problem hiding this comment.
I see. I agree that duplicating the entire function is probably worse. But the outer function being called ConsumeWholeObject when it can also consume multiple objects and the inner function ConsumeDocument when normally a document contains an object, not the other way around, is misleading IMO.
Maybe the function can still be called Find (as the two functions calling it) and the boolean flag can be last
| // XXX Hopefully this upholds for all std::string implementations | ||
| DCHECK_GE(buffer_.capacity() - buffer_.size(), simdjson::SIMDJSON_PADDING); |
There was a problem hiding this comment.
We could avoid the comment and assertion if we instead resize the buffer (instead of only calling reserve) to include padding and then construct an intermediate std::string_view{buffer_.data(), partial.size() + block.size()}
Rationale for this change
This PR continues the simdjson migration by replacing the RapidJSON-based JSON boundary detection used by the JSON chunker.
The existing implementation uses RapidJSON's streaming parser to identify complete JSON values. This change replaces that logic with the simdjson streaming parser.
Are these changes tested?
Yes, by existing tests.
Are there any user-facing changes?
No, except perhaps slight differences in error message and timing of error reporting when reading from an invalid JSON stream (i.e. there may be cases where a JSON parse error is reported earlier or later while iterating over RecordBatches).