Skip to content

perf: optimize spark_get_json_object (4x faster)#4907

Open
andygrove wants to merge 2 commits into
apache:mainfrom
andygrove:auto-opt/spark_get_json_object-datafusion-comet-20260713-094404
Open

perf: optimize spark_get_json_object (4x faster)#4907
andygrove wants to merge 2 commits into
apache:mainfrom
andygrove:auto-opt/spark_get_json_object-datafusion-comet-20260713-094404

Conversation

@andygrove

@andygrove andygrove commented Jul 13, 2026

Copy link
Copy Markdown
Member

This PR was created by an LLM as a draft PR. I will mark it as ready for review after human review.

Which issue does this PR close?

N/A

Rationale for this change

Optimize existing expression.

What changes are included in this PR?

Replaced the full serde_json::Value materialization of each row with a streaming DeserializeSeed path walk that skips non-matching subtrees via IgnoredAny and allocates only the matched subtree, eliminating per-key and per-value heap allocations for the whole document.

How are these changes tested?

Existing tests.

Benchmark (criterion):

  • nested_field: 75.342% faster (base 7961939ns -> cand 1963265ns)
  • top_level_field: 76.842% faster (base 7968457ns -> cand 1845346ns)
  • array_index: 75.091% faster (base 7562066ns -> cand 1883639ns)

Full criterion output:

get_json_object/top_level_field
                        time:   [1.8425 ms 1.8451 ms 1.8481 ms]
                        change: [−76.936% −76.842% −76.753%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 10 outliers among 100 measurements (10.00%)
  3 (3.00%) low mild
  3 (3.00%) high mild
  4 (4.00%) high severe
get_json_object/nested_field
                        time:   [1.9589 ms 1.9639 ms 1.9682 ms]
                        change: [−75.504% −75.342% −75.196%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 6 outliers among 100 measurements (6.00%)
  5 (5.00%) low mild
  1 (1.00%) high mild
get_json_object/array_index
                        time:   [1.8813 ms 1.8854 ms 1.8907 ms]
                        change: [−75.236% −75.091% −74.962%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 4 outliers among 100 measurements (4.00%)
  1 (1.00%) high mild
  3 (3.00%) high severe

@andygrove andygrove changed the title perf: optimize spark_get_json_object in datafusion-comet-spark-expr perf: optimize spark_get_json_object (4x faster) Jul 13, 2026
@andygrove andygrove marked this pull request as ready for review July 13, 2026 16:14

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

First pass, thanks @andygrove! Mostly tests.

Comment on lines +290 to +295
fn extract_no_wildcard(json_str: &str, segments: &[PathSegment]) -> Option<Value> {
let mut de = serde_json::Deserializer::from_str(json_str);
let found = PathSeed { segments }.deserialize(&mut de).ok()?;
de.end().ok()?;
found
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

native/spark-expr/src/string_funcs/get_json_object.rs:290-295 (extract_no_wildcard) relies on three things staying true to preserve parity: de.end() (line 293) rejecting trailing content, visit_map draining every entry after a match (lines 362-370), and visit_seq calling IgnoredAny.visit_seq(seq) after a match (line 392). These are the load-bearing lines of the whole PR, since they are what makes "streaming stop-early" still behave like "full parse". None of them is covered by a test. If a future refactor drops the drain or the de.end(), every existing test still passes because they all use well-formed single-value documents.

Add tests that only pass if the whole document is still validated after the match:

#[test]
fn test_match_then_trailing_garbage_is_null() {
    let path = parse_json_path("$.a").unwrap();
    assert_eq!(evaluate_path(r#"{"a":1} garbage"#, &path), None);
}

#[test]
fn test_match_then_malformed_sibling_is_null() {
    let path = parse_json_path("$.a").unwrap();
    // "a" matches early, but "b" is malformed; a full parse rejects this.
    assert_eq!(evaluate_path(r#"{"a":1,"b":}"#, &path), None);
}

#[test]
fn test_array_match_then_malformed_element_is_null() {
    let path = parse_json_path("$[0]").unwrap();
    assert_eq!(evaluate_path(r#"[1,,]"#, &path), None);
}

Comment on lines +360 to +361
// Every entry is visited so that a duplicated key resolves to its last
// occurrence, as it would in a parsed object.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

native/spark-expr/src/string_funcs/get_json_object.rs:360-361 visits every entry so a duplicated key "resolves to its last occurrence, as it would in a parsed object." That is correct for the old serde_json + preserve_order behavior (IndexMap overwrites the value, so .get returns the last), so this PR is faithful to the old Comet code. It is worth pinning with a test because the visit-every-entry loop is exactly what makes it work, and a future "break after match" optimization would silently flip it to first-wins:

#[test]
fn test_duplicate_key_last_wins() {
    let path = parse_json_path("$.a").unwrap();
    assert_eq!(evaluate_path(r#"{"a":1,"a":2}"#, &path), Some("2".to_string()));
}

Separately, note for the record that both old and new Comet diverge from Spark here. Spark's GetJsonObjectEvaluator.evaluatePath (sql/catalyst/.../json/JsonExpressionEvalUtils.scala:478-488) stops at the first matching field once dirty is set, so Spark returns the first occurrence, not the last. This is a pre-existing Comet bug, not something #4907 introduces, but the comment claiming parity with "a parsed object" is only true against serde, not against Spark. Consider filing a follow-up issue rather than expanding this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I filed #4947

segments: &'a [PathSegment],
}

impl<'de> Visitor<'de> for SegmentVisitor<'_> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

native/spark-expr/src/string_funcs/get_json_object.rs SegmentVisitor::visit_unit returns Ok(None), which is what makes $.a.b on {"a":null} return null (recurse into null with a remaining segment yields no match). The old code got this from Null.as_object()? -> None. Parity holds, but there is no test for a null encountered mid-path (the existing test_evaluate_null_value only covers a terminal null). Add:

#[test]
fn test_null_midpath_is_null() {
    let path = parse_json_path("$.a.b").unwrap();
    assert_eq!(evaluate_path(r#"{"a":null}"#, &path), None);
}

Add regression tests pinning the behaviors that keep the streaming
DeserializeSeed path walk equivalent to a full parse:
- trailing garbage after a match is rejected (de.end())
- a malformed sibling after a match is rejected (visit_map drain)
- a malformed array element after a match is rejected (visit_seq drain)
- a duplicated key resolves to its last occurrence (preserve_order parity)
- a null encountered mid-path yields no match (visit_unit)
@andygrove

Copy link
Copy Markdown
Member Author

Thanks @mbutrovich . I added the tests and filed #4947

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI, thanks @andygrove!

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.

2 participants