Skip to content

Speed up JSON schema inference by ~2.8x - #9494

Open
Rafferty97 wants to merge 6 commits into
apache:mainfrom
Rafferty97:json-schema
Open

Speed up JSON schema inference by ~2.8x#9494
Rafferty97 wants to merge 6 commits into
apache:mainfrom
Rafferty97:json-schema

Conversation

@Rafferty97

@Rafferty97 Rafferty97 commented Feb 28, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

This PR sets the groundwork for implementing #9482. It also delivers an approximate 2.8x speed to JSON schema inference.

I have refactored the code that infers the schema of JSON sources, specifically:

  • Simplify the type inference logic, removing special cases
  • Schema inference now consumes TapeDecoder, eliminating the need to materialise rows into serde_json::Values first
  • Use arena allocation for efficiency
  • Move ValueIter into its own module

Rationale for this change

While working on #9482, I saw a need and opportunity to refactor the schema inference code for JSON schemas. I also discovered the bug detailed in #9484.

These changes not only make the code more readible and predictable by eliminating a lot of special case handling, but make it trivial to create a new inference function for "single field" JSON reading.

They have also provided a significant performance boost to the schema inference functions. I added a simple benchmark for infer_json_schema, which yielded the following results on my machine, reflecting an approx. 2.8x speed up:

Before changes:
infer_json_schema/1000 time: [1.4443 ms 1.4616 ms 1.4793 ms]
thrpt: [85.336 MiB/s 86.366 MiB/s 87.401 MiB/s]

After changes:
infer_json_schema/1000 time: [517.79 µs 519.10 µs 520.54 µs]
thrpt: [242.51 MiB/s 243.18 MiB/s 243.80 MiB/s]
change:
time: [−64.919% −64.485% −64.043%] (p = 0.00 < 0.05)
thrpt: [+178.11% +181.57% +185.06%]

NOTE: I haven't rerun the above benchmark since making further changes to this PR in light of feedback.

What changes are included in this PR?

At a glance:

  • An overhaul of arrow-json/src/reader/schema.rs

Because this is a somewhat sizeable PR, I've done my best to break into a logical sequence of commits to hopefully assist with the review.

Are these changes tested?

Yes, the changes pass all existing unit tests.

I have also added an additional benchmark for the schema inference performance.

Are there any user-facing changes?

There are no API changes, except for the addition of the record_count method on ValueIter.

However, the error messages returned by infer_json_schema and its cousins will significantly change, with most of them condensed to a single "Expected {expected}, found {got}" template.

Note: Although this PR originally removed the "scalar-to-array" promotion logic that the previous implementation had, I have restored it to keep the scope of this PR narrow and easier to review. For further context on this behaviour and why it might be better to remove it, please see #9484.

@github-actions github-actions Bot added the arrow Changes to the arrow crate label Feb 28, 2026
@Rafferty97 Rafferty97 changed the title Refactor and improve performance of JSON schema inference Speed up JSON schema inference by ~2.8x Mar 2, 2026
Dandandan pushed a commit that referenced this pull request Mar 13, 2026
# Which issue does this PR close?

Split out from #9494 to make review easier. It simply adds a benchmark
for JSON schema inference.

# Rationale for this change

I have an open PR that significantly refactors the JSON schema inference
code, so I want confidence that not only is the new code correct, but
also has better performance than the existing code.

# What changes are included in this PR?

Adds a benchmark.

# Are these changes tested?

N/A

# Are there any user-facing changes?

No
Comment thread arrow-json/src/reader/value_iter.rs Outdated
alamb pushed a commit that referenced this pull request Mar 18, 2026
…ion (#9557)

# Which issue does this PR close?

Another smaller PR extracted from #9494.

# Rationale for this change

I've moved `ValueIter` into its own module because it's already
self-contained, and because that will make it easier to review the
changes I have made to `arrow-json/src/reader/schema.rs`.

I've also added a public `record_count` function to `ValueIter` - which
can be used to simplify consuming code in Datafusion which is currently
tracking it separately.

# What changes are included in this PR?

* Moved `ValueIter` into own module
* Added `record_count` method to `ValueIter`

# Are these changes tested?

Yes.

# Are there any user-facing changes?

Addition of one new public method, `ValueIter::record_count`.
@alamb

alamb commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

@Rafferty97, can you please merge up this PR to resolve the conflicts and then we can run the benchmarks again to confirm the results

@Rafferty97

Copy link
Copy Markdown
Contributor Author

@Rafferty97, can you please merge up this PR to resolve the conflicts and then we can run the benchmarks again to confirm the results

Done :)

@alamb

This comment has been minimized.

@adriangbot

This comment has been minimized.

@adriangbot

This comment has been minimized.

@alamb

alamb commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

infer_json_schema/1000 1.00 733.2±1.71µs 172.2 MB/sec 2.11 1547.1±11.79µs 81.6 MB/sec

That is certainly a nice result ❤️

@alamb alamb 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.

Thanks again for this @Rafferty97 and for your patience

I took a look at the PR. my major comments are:

  1. Can you please document the design / rationale (and why are there LazyLocks being used for what seem to be very small enums)
  2. Can you ensure the behavior is the same as the existing code?

If we want to change the inference behavior I recommend proposing those changes in a separate PR so that we can evaluate the potential impact.

Comment thread arrow-json/src/reader/schema/infer.rs Outdated
@@ -1,4 +0,0 @@
{"a":1, "b":[2.0, 1.3, -6.1], "c":[false, true], "d":4.1}

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.

What is the purpose of removing this file?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This file was used by tests related to an inference rule that coerces a mix of scalar and array values into an array type. I've removed this rule because the JSON reader can't actually do this coercion, so I figured it was better to error out instead.

I could reinstate these files and test that they cause schema inference to fail - but I'm unsure how useful that actually is?

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.

I mean in general, if this PR causes inference that previously used to work to start failing, I am not sure we would want to merge it

BY removing hte tests, it is harder to evaluate what effective behavior change in this PR is

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.

I've removed this rule because the JSON reader can't actually do this coercion, so I figured it was better to error out instead.

I don't understand -- on main test_json_infer_schema read this file and inferred its schema as

  let schema = Schema::new(vec![
            Field::new("a", DataType::Int64, true),
            Field::new("b", list_type_of(DataType::Float64), true),
            Field::new("c", list_type_of(DataType::Boolean), true),
            Field::new("d", list_type_of(DataType::Utf8), true),
        ]);

What is not supported about this?

Comment thread arrow-json/test/data/arrays.json.gz Outdated

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.

Given this file is so small (133 bytes), can you please unzip it to make the contents more explicit and easier to review and track changes

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The contents are identical to arrays.json. I was following the pattern set by mixed_arrays.json(.gz). I needed to create this file for tests that previously used mixed_arrays.json(.gz) which were deleted. Those files were deleted because they aren't readable by the JSON reader - they rely on coercion semantics that no longer exist.

Comment thread arrow-json/src/reader/schema/infer.rs Outdated
Comment thread arrow-json/src/reader/schema/infer.rs Outdated
Comment thread arrow-json/src/reader/schema.rs
Comment thread arrow-json/src/reader/schema.rs
Comment thread arrow-json/src/reader/schema/infer.rs Outdated
}

/// The type of a JSON value
pub enum JsonType {

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.

I found it strange that the Json type and tape value are now in the infer module -- they seem more widely applicable than just for schema inference

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's fair. I've moved them to a separate module within schema for better code organisation.

@Rafferty97

Copy link
Copy Markdown
Contributor Author

Thanks again for this @Rafferty97 and for your patience

I took a look at the PR. my major comments are:

  1. Can you please document the design / rationale (and why are there LazyLocks being used for what seem to be very small enums)
  2. Can you ensure the behavior is the same as the existing code?

If we want to change the inference behavior I recommend proposing those changes in a separate PR so that we can evaluate the potential impact.

Hi @alamb, thank you for taking a look over the PR and for the detailed feedback.

The LazyLocks are an optimisation to avoid allocating a bunch of identical Arcs for the primitive types. You're right that this warrants some explanatory comments.

The behaviour intentionally diverges from the existing code, because the existing code would perform coercions that the actual JSON reader itself doesn't do. So, when such a JSON file is encountered, the previous code would infer successfully but the actual reading into record batches would fail. This new code would return an error at inference time, which I think is more useful and less surprising to the end user.

@Rafferty97

Copy link
Copy Markdown
Contributor Author

@alamb This one's ready for review again :)

I've removed bumpalo and just used Arcs instead. I've also cleaned up the use of lazy locks and added comments where appropriate.

If you're concerned about the removal of the scalar-to-array coercion logic, I can add it back in, but I think it's better not to unless we plan to implement that coercion logic in the JSON reader itself.

@alamb

This comment has been minimized.

1 similar comment
@alamb

This comment has been minimized.

@adriangbot

This comment was marked as duplicate.

@adriangbot

This comment was marked as duplicate.

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                                        json-schema                            main
-----                                        -----------                            ----
decode_binary_hex_json                       1.00     13.7±0.16ms        ? ?/sec    1.01     13.8±0.17ms        ? ?/sec
decode_binary_view_hex_json                  1.01     14.5±0.08ms        ? ?/sec    1.00     14.4±0.13ms        ? ?/sec
decode_fixed_binary_hex_json                 1.01     14.0±0.08ms        ? ?/sec    1.00     13.9±0.13ms        ? ?/sec
decode_list_long_i64_json/131072             1.00    305.3±2.05ms   256.5 MB/sec    1.01    309.2±2.13ms   253.3 MB/sec
decode_list_long_i64_serialize               1.00    189.6±5.84ms        ? ?/sec    1.02    193.5±6.16ms        ? ?/sec
decode_list_short_i64_json/131072            1.00     19.8±0.04ms   264.1 MB/sec    1.01     19.9±0.03ms   262.0 MB/sec
decode_list_short_i64_serialize              1.00     11.6±0.61ms        ? ?/sec    1.02     11.9±0.73ms        ? ?/sec
decode_wide_object_i64_json                  1.00    470.6±7.98ms        ? ?/sec    1.02    481.6±6.34ms        ? ?/sec
decode_wide_object_i64_serialize             1.00   441.5±16.55ms        ? ?/sec    1.01   445.0±15.87ms        ? ?/sec
decode_wide_projection_full_json/131072      1.00   782.7±10.98ms   222.3 MB/sec    1.02   797.8±10.18ms   218.1 MB/sec
decode_wide_projection_narrow_json/131072    1.01    450.2±2.28ms   386.5 MB/sec    1.00    446.2±2.47ms   390.0 MB/sec
infer_json_schema/1000                       1.00    780.4±2.35µs   161.7 MB/sec    1.99  1551.3±11.73µs    81.4 MB/sec
large_bench_primitive                        1.00   1527.9±2.28µs        ? ?/sec    1.00   1530.6±3.86µs        ? ?/sec
small_bench_list                             1.01      8.1±0.02µs        ? ?/sec    1.00      8.0±0.01µs        ? ?/sec
small_bench_primitive                        1.01      4.5±0.01µs        ? ?/sec    1.00      4.4±0.01µs        ? ?/sec
small_bench_primitive_with_utf8view          1.02      4.5±0.02µs        ? ?/sec    1.00      4.5±0.01µs        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 306.5s
Peak memory 3.5 GiB
Avg memory 2.9 GiB
CPU user 288.9s
CPU sys 17.4s
Disk read 4.0 KiB
Disk write 617.7 MiB

branch

Metric Value
Wall time 308.1s
Peak memory 3.5 GiB
Avg memory 2.9 GiB
CPU user 291.1s
CPU sys 16.9s
Disk read 0 B
Disk write 980.0 KiB

File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                                        json-schema                            main
-----                                        -----------                            ----
decode_binary_hex_json                       1.00     13.8±0.02ms        ? ?/sec    1.01     14.0±0.03ms        ? ?/sec
decode_binary_view_hex_json                  1.01     14.4±0.03ms        ? ?/sec    1.00     14.3±0.02ms        ? ?/sec
decode_fixed_binary_hex_json                 1.02     14.1±0.02ms        ? ?/sec    1.00     13.9±0.03ms        ? ?/sec
decode_list_long_i64_json/131072             1.00    303.4±0.33ms   258.1 MB/sec    1.01    307.6±0.45ms   254.5 MB/sec
decode_list_long_i64_serialize               1.00    184.7±4.69ms        ? ?/sec    1.02    189.2±4.45ms        ? ?/sec
decode_list_short_i64_json/131072            1.00     19.8±0.04ms   264.0 MB/sec    1.01     19.9±0.02ms   262.2 MB/sec
decode_list_short_i64_serialize              1.00     11.2±0.17ms        ? ?/sec    1.03     11.6±0.17ms        ? ?/sec
decode_wide_object_i64_json                  1.00    466.4±5.38ms        ? ?/sec    1.03    480.0±4.39ms        ? ?/sec
decode_wide_object_i64_serialize             1.01   432.7±13.65ms        ? ?/sec    1.00   428.8±13.08ms        ? ?/sec
decode_wide_projection_full_json/131072      1.07   860.3±99.44ms   202.2 MB/sec    1.00    801.0±6.84ms   217.2 MB/sec
decode_wide_projection_narrow_json/131072    1.01    449.4±0.30ms   387.2 MB/sec    1.00    444.6±0.38ms   391.4 MB/sec
infer_json_schema/1000                       1.00    781.5±3.03µs   161.5 MB/sec    2.01  1572.4±33.64µs    80.3 MB/sec
large_bench_primitive                        1.00   1528.0±2.39µs        ? ?/sec    1.01   1539.4±5.47µs        ? ?/sec
small_bench_list                             1.00      8.0±0.02µs        ? ?/sec    1.02      8.1±0.05µs        ? ?/sec
small_bench_primitive                        1.00      4.5±0.03µs        ? ?/sec    1.00      4.5±0.03µs        ? ?/sec
small_bench_primitive_with_utf8view          1.00      4.5±0.02µs        ? ?/sec    1.00      4.5±0.01µs        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 310.5s
Peak memory 3.5 GiB
Avg memory 2.9 GiB
CPU user 292.2s
CPU sys 18.1s
Disk read 0 B
Disk write 1.5 GiB

branch

Metric Value
Wall time 313.4s
Peak memory 3.5 GiB
Avg memory 2.9 GiB
CPU user 296.3s
CPU sys 16.9s
Disk read 0 B
Disk write 2.0 MiB

File an issue against this benchmark runner

@Rafferty97

Rafferty97 commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Hi @alamb, thanks for having another look over the PR. I definitely appreciate that it's a little on the heavier side, and the aversion to changing the code's behaviour.

You're right that these changes would cause some schema inferences that previously succeeded to return an error. In particular, it would now reject cases where a given key appears with both scalar and array values, which is what mixed_arrays.json specifically tests:

{"a":1, "b":[2.0, 1.3, -6.1], "c":[false, true], "d":4.1}
{"a":-10, "b":[2.0, 1.3, -6.1], "c":null, "d":null}
{"a":2, "b":[2.0, null, -6.1], "c":[false, null], "d":"text"}
{"a":3, "b":4, "c": true, "d":[1, false, "array", 2.4]}

Here, the keys b, c and d appear in some rows as scalar values, and in other rows as array values. In the existing code, these keys get inferred as array types, on the assumption that when the file is read into actual RecordBatches, those scalar values will be coerced into single-element arrays.

However, the JSON reader doesn't actually do this. It appears that it used to but that this functionality was removed. So, even though the schema inference succeeds, the JSON file will fail to be read at query execution time. So, my opinion is that it would actually be better for the inference itself to fail, as this is more informative to the user.

With that being said, I'm open to preserving the existing behaviour for now to get the PR over the line. If that's your preference, I'll re-implement that functionality and reinstate those tests I removed.

I also agree with your earlier comment about preferring not to commit gziped files in the repo itself. I'd be happy to change those tests that need them to instead do the compression themselves rather than relying on an already-compressed file existing.

@alamb

alamb commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

However, the JSON reader doesn't actually do this. It appears that it used to but that this functionality was removed. So, even though the schema inference succeeds, the JSON file will fail to be read at query execution time. So, my opinion is that it would actually be better for the inference itself to fail, as this is more informative to the user.

Is there some test that shows this (as in it tries and fails to read such a file)?

I am sorry for ht eback and forth but I don't likely have the time to do investigation into these corner cases -- it is easy for me to review code that makes things faster or is additive behavior wise, but trying to validate / understand the potential downstream impacts of chaging behavior takes me much longer and thus is less likely to get time and also has a higher bar for inclusion I think

@Rafferty97

Copy link
Copy Markdown
Contributor Author

Hey @alamb,

I completely understand your time constraints and caution against introducing potentially breaking changes.

To make this simpler to review and merge, I've reinstated the scalar-to-array coercion logic that the original implementation did, and thus reinstated the tests that I had changed/removed.

There are still some minor behaviour changes which required some tweaks to the tests, but they're ones I wouldn't expect to have downstream impact:

  • Inferred field order now matches the input rather than alphabetised, arguably better than the previous behaviour
  • Error messages have different specific wordings

If there's interest in resolving the mismatch between schema inference and file reading down the line, it can be contained to this ticket I'd opened previously: #9484

@Jefffrey

Copy link
Copy Markdown
Contributor

run benchmark json_reader

@adriangbot

This comment was marked as outdated.

@Jefffrey

Copy link
Copy Markdown
Contributor

could we merge up from main to resolve the conflict, and also update the PR body to reflect the latest changes

@Rafferty97
Rafferty97 force-pushed the json-schema branch 2 times, most recently from f75f077 to 4b2c31b Compare August 22, 2026 03:22
@Jefffrey

Copy link
Copy Markdown
Contributor

run benchmark json_reader

@adriangbot

This comment was marked as duplicate.

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing json-schema (4b2c31b) to 5ec9eaf (merge-base) diff

Run configuration
run benchmark json_reader
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                                        json-schema                            main
-----                                        -----------                            ----
decode_binary_hex_json                       1.00     13.8±0.06ms        ? ?/sec    1.03     14.2±0.08ms        ? ?/sec
decode_binary_view_hex_json                  1.01     14.7±0.07ms        ? ?/sec    1.00     14.6±0.06ms        ? ?/sec
decode_fixed_binary_hex_json                 1.01     14.3±0.05ms        ? ?/sec    1.00     14.2±0.06ms        ? ?/sec
decode_list_long_json/131072                 1.02    315.2±1.71ms   248.4 MB/sec    1.00    309.7±2.30ms   252.9 MB/sec
decode_list_long_serialize                   1.00    195.7±3.72ms        ? ?/sec    1.12    218.7±3.81ms        ? ?/sec
decode_list_short_json/131072                1.03     20.4±0.08ms   255.4 MB/sec    1.00     19.9±0.19ms   262.6 MB/sec
decode_list_short_serialize                  1.00     13.2±0.57ms        ? ?/sec    1.01     13.4±0.36ms        ? ?/sec
decode_list_view_long_json/131072            1.02    315.1±1.71ms   248.5 MB/sec    1.00    309.4±2.15ms   253.1 MB/sec
decode_list_view_long_serialize              1.00    197.7±3.21ms        ? ?/sec    1.11    220.1±3.35ms        ? ?/sec
decode_list_view_short_json/131072           1.03     20.7±0.11ms   251.9 MB/sec    1.00     20.0±0.09ms   260.5 MB/sec
decode_list_view_short_serialize             1.00     13.4±0.37ms        ? ?/sec    1.03     13.7±0.35ms        ? ?/sec
decode_map_large_json/131072                 1.02    274.1±1.85ms   277.1 MB/sec    1.00    268.8±2.26ms   282.6 MB/sec
decode_map_large_serialize                   1.00    321.8±7.36ms        ? ?/sec    1.01    326.0±7.92ms        ? ?/sec
decode_map_small_json/131072                 1.03     31.3±0.26ms   262.7 MB/sec    1.00     30.5±0.20ms   269.9 MB/sec
decode_map_small_serialize                   1.00     23.7±0.61ms        ? ?/sec    1.05     24.9±0.60ms        ? ?/sec
decode_ree_long_json/131072                  1.10      6.5±0.01ms   232.7 MB/sec    1.00      5.9±0.01ms   256.3 MB/sec
decode_ree_long_serialize                    1.02      5.0±0.18ms        ? ?/sec    1.00      4.9±0.09ms        ? ?/sec
decode_ree_short_json/131072                 1.04      6.7±0.02ms   257.5 MB/sec    1.00      6.5±0.01ms   267.5 MB/sec
decode_ree_short_serialize                   1.00      5.0±0.08ms        ? ?/sec    1.00      5.0±0.09ms        ? ?/sec
decode_wide_object_json/131072               1.00    494.9±5.11ms   194.4 MB/sec    1.00    494.7±4.10ms   194.5 MB/sec
decode_wide_object_serialize                 1.00   469.9±11.13ms        ? ?/sec    1.02   479.2±10.95ms        ? ?/sec
decode_wide_projection_full_json/131072      1.01    820.0±8.19ms   212.2 MB/sec    1.00    808.4±6.51ms   215.2 MB/sec
decode_wide_projection_narrow_json/131072    1.02    461.4±1.50ms   377.1 MB/sec    1.00    451.2±2.48ms   385.7 MB/sec
infer_json_schema/1000                       1.00    793.8±2.18µs   159.0 MB/sec    1.94  1542.7±26.98µs    81.8 MB/sec
large_bench_primitive                        1.00   1502.8±4.20µs        ? ?/sec    1.00   1496.3±3.28µs        ? ?/sec
small_bench_list                             1.00      6.8±0.03µs        ? ?/sec    1.00      6.8±0.02µs        ? ?/sec
small_bench_primitive                        1.00      4.0±0.02µs        ? ?/sec    1.01      4.0±0.03µs        ? ?/sec
small_bench_primitive_with_utf8view          1.00      4.0±0.02µs        ? ?/sec    1.01      4.0±0.02µs        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 465.1s
Peak memory 1.4 GiB
Avg memory 927.5 MiB
CPU user 419.1s
CPU sys 41.0s
Peak spill 0 B

branch

Metric Value
Wall time 465.1s
Peak memory 1.4 GiB
Avg memory 920.1 MiB
CPU user 421.8s
CPU sys 38.8s
Peak spill 0 B

File an issue against this benchmark runner

@Jefffrey

Copy link
Copy Markdown
Contributor

run benchmark json_reader

@adriangbot

This comment was marked as duplicate.

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing json-schema (4b2c31b) to 5ec9eaf (merge-base) diff

Run configuration
run benchmark json_reader
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                                        json-schema                            main
-----                                        -----------                            ----
decode_binary_hex_json                       1.00     13.3±0.05ms        ? ?/sec    1.04     13.9±0.05ms        ? ?/sec
decode_binary_view_hex_json                  1.01     14.3±0.05ms        ? ?/sec    1.00     14.2±0.04ms        ? ?/sec
decode_fixed_binary_hex_json                 1.00     13.8±0.05ms        ? ?/sec    1.00     13.9±0.05ms        ? ?/sec
decode_list_long_json/131072                 1.02    307.3±1.36ms   254.8 MB/sec    1.00    302.2±1.65ms   259.1 MB/sec
decode_list_long_serialize                   1.00    181.9±1.37ms        ? ?/sec    1.12    203.7±3.97ms        ? ?/sec
decode_list_short_json/131072                1.03     20.3±0.07ms   257.8 MB/sec    1.00     19.7±0.08ms   264.9 MB/sec
decode_list_short_serialize                  1.02     11.5±0.15ms        ? ?/sec    1.00     11.3±0.12ms        ? ?/sec
decode_list_view_long_json/131072            1.02    307.4±1.45ms   254.7 MB/sec    1.00    301.9±1.64ms   259.3 MB/sec
decode_list_view_long_serialize              1.00    183.2±1.43ms        ? ?/sec    1.12    205.2±2.90ms        ? ?/sec
decode_list_view_short_json/131072           1.03     20.5±0.07ms   254.3 MB/sec    1.00     20.0±0.07ms   261.7 MB/sec
decode_list_view_short_serialize             1.00     11.6±0.12ms        ? ?/sec    1.00     11.6±0.09ms        ? ?/sec
decode_map_large_json/131072                 1.02    262.3±1.03ms   289.6 MB/sec    1.00    257.8±1.55ms   294.6 MB/sec
decode_map_large_serialize                   1.00    285.1±3.47ms        ? ?/sec    1.01    286.9±5.67ms        ? ?/sec
decode_map_small_json/131072                 1.02     30.8±0.10ms   266.7 MB/sec    1.00     30.1±0.17ms   273.0 MB/sec
decode_map_small_serialize                   1.02     20.7±0.21ms        ? ?/sec    1.00     20.2±0.24ms        ? ?/sec
decode_ree_long_json/131072                  1.00      6.1±0.08ms   247.4 MB/sec    1.02      6.2±0.02ms   243.6 MB/sec
decode_ree_long_serialize                    1.00      4.3±0.03ms        ? ?/sec    1.00      4.3±0.05ms        ? ?/sec
decode_ree_short_json/131072                 1.03      6.7±0.02ms   258.9 MB/sec    1.00      6.5±0.01ms   267.6 MB/sec
decode_ree_short_serialize                   1.00      4.6±0.03ms        ? ?/sec    1.00      4.6±0.04ms        ? ?/sec
decode_wide_object_json/131072               1.00    471.0±5.56ms   204.3 MB/sec    1.00    469.1±3.29ms   205.2 MB/sec
decode_wide_object_serialize                 1.00    432.5±6.78ms        ? ?/sec    1.01    435.3±7.23ms        ? ?/sec
decode_wide_projection_full_json/131072      1.00    777.6±8.79ms   223.8 MB/sec    1.00    775.2±8.23ms   224.5 MB/sec
decode_wide_projection_narrow_json/131072    1.02    448.8±1.01ms   387.7 MB/sec    1.00    438.8±2.61ms   396.5 MB/sec
infer_json_schema/1000                       1.00    799.0±6.52µs   158.0 MB/sec    1.92  1536.7±27.67µs    82.1 MB/sec
large_bench_primitive                        1.01   1497.1±2.78µs        ? ?/sec    1.00   1487.8±2.40µs        ? ?/sec
small_bench_list                             1.00      6.8±0.01µs        ? ?/sec    1.00      6.8±0.01µs        ? ?/sec
small_bench_primitive                        1.00      4.0±0.02µs        ? ?/sec    1.01      4.0±0.02µs        ? ?/sec
small_bench_primitive_with_utf8view          1.00      4.0±0.01µs        ? ?/sec    1.02      4.1±0.01µs        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 455.1s
Peak memory 1.4 GiB
Avg memory 927.3 MiB
CPU user 413.4s
CPU sys 37.0s
Peak spill 0 B

branch

Metric Value
Wall time 455.1s
Peak memory 1.4 GiB
Avg memory 919.3 MiB
CPU user 415.4s
CPU sys 35.1s
Peak spill 0 B

File an issue against this benchmark runner

@Jefffrey Jefffrey 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.

i had codex do a comparison with main and found these differences, which i categorized based on being major/minor differences (these tests assert the result as of main)

major

    #[test]
    fn test_read_all() {
        let mut data = "{}\n".repeat(1024);
        data.push_str("{\"late\":true}\n");
        let (schema, _) = infer_json_schema(Cursor::new(data), None).unwrap();
        assert_eq!(
            schema.field(0),
            &Field::new("late", DataType::Boolean, true)
        );
    }
  • it seems we are only reading the first 1024 records?
    #[test]
    fn test_reject_invalid_json() {
        let result = infer_json_schema(Cursor::new(r#"{"a":1 "b":2}"#), None);
        assert!(result.is_err());

        let result = infer_json_schema(Cursor::new(r#"{"a":1e}"#), None);
        assert!(result.is_err());
    }
  • these are some example json which apparently pass schema inference now
    #[test]
    fn test_empty() {
        let values = std::iter::empty::<Result<Value, ArrowError>>();
        let schema = infer_json_schema_from_iterator(values).unwrap();
        assert_eq!(schema, Schema::empty());
    }
  • empty input was producing empty schema, but not anymore
    #[test]
    fn test_consistent_order() {
        let data = r#"{"z":1,"a":2}"#;
        let (stream_schema, _) = infer_json_schema(Cursor::new(data), None).unwrap();
        let value = serde_json::from_str::<Value>(data).unwrap();
        let iterator_schema = infer_json_schema_from_iterator(std::iter::once(Ok(value))).unwrap();
        assert_eq!(stream_schema, iterator_schema);
    }
  • ordering of fields in inferred schema has changed, especially as now the two public functions aren't consistent with each other
    #[test]
    fn test_duplicate_fields() {
        let data = r#"{"a":1,"a":"value"}"#;
        let (schema, _) = infer_json_schema(Cursor::new(data), None).unwrap();
        assert_eq!(
            schema,
            Schema::new(vec![Field::new("a", DataType::Utf8, true)])
        );
    }
  • duplicate fields are being inferred as two different fields; previous behaviour was to use latter value, though maybe that is also confusing

minor

max_read_records seems to be applied after reading, so it technically can overshoot how many records were read (even if theyre not used in inference). this could be surprising for consumers using infer_json_schema() as it may now move the cursor more than before. another consequence of this is invalid trailing data isnt ignored, for example:

    #[test]
    fn test_ignore_trailing_invalid() {
        let data = b"{\"a\":1}\nthis is not JSON\n";
        let (schema, record_count) = infer_json_schema(Cursor::new(data), Some(1)).unwrap();
        assert_eq!(record_count, 1);
        assert_eq!(schema.field(0), &Field::new("a", DataType::Int64, true));
    }
  • we expect to read only one record, but since we read beyond that it errors
    #[test]
    fn test_line_delimited() {
        let result = infer_json_schema(Cursor::new(r#"{"a":1} {"b":2}"#), None);
        assert!(result.is_err());
    }
  • this ties into previous points, but it seems we now allow parsing json records that arent newline delimited; it seems we didnt exactly document that we only expect newline delimited json however 🤔
    #[test]
    fn test_complex_null() {
        let result = infer_json_schema(Cursor::new(r#"{"a":[null,{"b":1}]}"#), None);
        assert!(result.is_err());
    }
  • nulls in a complex object are now allowed - this looks to be a bug in our current code actually
    #[test]
    fn test_reject_only_null() {
        let result = infer_json_schema(Cursor::new("null\n"), None);
        assert!(result.is_err());
    }
  • only null input seems to be accepted instead of rejected

i havent taken too close a look at the code in this PR, but it does seem quite a bit of behaviour is changing (even if the benchmark numbers are pretty good 🚀). it is to be said though that our existing test suite is quite lacking to have not asserted these cases

@Rafferty97

Copy link
Copy Markdown
Contributor Author

i had codex do a comparison with main and found these differences, which i categorized based on being major/minor differences (these tests assert the result as of main)

major

    #[test]
    fn test_read_all() {
        let mut data = "{}\n".repeat(1024);
        data.push_str("{\"late\":true}\n");
        let (schema, _) = infer_json_schema(Cursor::new(data), None).unwrap();
        assert_eq!(
            schema.field(0),
            &Field::new("late", DataType::Boolean, true)
        );
    }
  • it seems we are only reading the first 1024 records?
    #[test]
    fn test_reject_invalid_json() {
        let result = infer_json_schema(Cursor::new(r#"{"a":1 "b":2}"#), None);
        assert!(result.is_err());

        let result = infer_json_schema(Cursor::new(r#"{"a":1e}"#), None);
        assert!(result.is_err());
    }
  • these are some example json which apparently pass schema inference now
    #[test]
    fn test_empty() {
        let values = std::iter::empty::<Result<Value, ArrowError>>();
        let schema = infer_json_schema_from_iterator(values).unwrap();
        assert_eq!(schema, Schema::empty());
    }
  • empty input was producing empty schema, but not anymore
    #[test]
    fn test_consistent_order() {
        let data = r#"{"z":1,"a":2}"#;
        let (stream_schema, _) = infer_json_schema(Cursor::new(data), None).unwrap();
        let value = serde_json::from_str::<Value>(data).unwrap();
        let iterator_schema = infer_json_schema_from_iterator(std::iter::once(Ok(value))).unwrap();
        assert_eq!(stream_schema, iterator_schema);
    }
  • ordering of fields in inferred schema has changed, especially as now the two public functions aren't consistent with each other
    #[test]
    fn test_duplicate_fields() {
        let data = r#"{"a":1,"a":"value"}"#;
        let (schema, _) = infer_json_schema(Cursor::new(data), None).unwrap();
        assert_eq!(
            schema,
            Schema::new(vec![Field::new("a", DataType::Utf8, true)])
        );
    }
  • duplicate fields are being inferred as two different fields; previous behaviour was to use latter value, though maybe that is also confusing

minor

max_read_records seems to be applied after reading, so it technically can overshoot how many records were read (even if theyre not used in inference). this could be surprising for consumers using infer_json_schema() as it may now move the cursor more than before. another consequence of this is invalid trailing data isnt ignored, for example:

    #[test]
    fn test_ignore_trailing_invalid() {
        let data = b"{\"a\":1}\nthis is not JSON\n";
        let (schema, record_count) = infer_json_schema(Cursor::new(data), Some(1)).unwrap();
        assert_eq!(record_count, 1);
        assert_eq!(schema.field(0), &Field::new("a", DataType::Int64, true));
    }
  • we expect to read only one record, but since we read beyond that it errors
    #[test]
    fn test_line_delimited() {
        let result = infer_json_schema(Cursor::new(r#"{"a":1} {"b":2}"#), None);
        assert!(result.is_err());
    }
  • this ties into previous points, but it seems we now allow parsing json records that arent newline delimited; it seems we didnt exactly document that we only expect newline delimited json however 🤔
    #[test]
    fn test_complex_null() {
        let result = infer_json_schema(Cursor::new(r#"{"a":[null,{"b":1}]}"#), None);
        assert!(result.is_err());
    }
  • nulls in a complex object are now allowed - this looks to be a bug in our current code actually
    #[test]
    fn test_reject_only_null() {
        let result = infer_json_schema(Cursor::new("null\n"), None);
        assert!(result.is_err());
    }
  • only null input seems to be accepted instead of rejected

i havent taken too close a look at the code in this PR, but it does seem quite a bit of behaviour is changing (even if the benchmark numbers are pretty good 🚀). it is to be said though that our existing test suite is quite lacking to have not asserted these cases

Thanks for taking the time to review the PR. I'm a little surprised to see this many changes in behaviour, but when I get that chance I'll review them and either fix or justify these cases.

@alamb

alamb commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Perhaps we can make a separate PR that adds the test coverage for those cases (so we are protected from regressions) and then we can update this PR to ensure the tests don't fail

@Rafferty97

Rafferty97 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Jefffrey and @alamb, I've now had the time to review each of those test cases individually. Please see my questions and comments below, grouped into a few themes:

Fixes

  • test_read_all
  • test_empty
  • test_ignore_trailing_invalid
  • test_reject_only_null

These tests exercised some edge cases I did miss, so I've fixed those issues and these tests are now green on my branch. Thanks for finding them.

More tolerant parsing

  • test_reject_invalid_json
  • test_line_delimited

My new schema inference implementation uses the TapeDecoder, which is much more tolerant when parsing invalid JSON input. For example, it treats commas and new lines as whitespace, as their removal from the grammar doesn't introduce any ambiguity. So, the above test cases are instances where the existing schema inference is stricter than the JSON reader, and my new implementation brings them closer to alignment.

With respect to accepting more than just newline-delimited JSON, I think this is a very useful feature of the JSON reader, as it permits parsing regular JSON documents as a single row without having to strip the newlines first.

I think, in general, we should prefer not to reject malformed input where a sensible parse does exist and is easy to handle. So, for these two cases, I would suggest either excluding them, or rewriting them to assert a successful schema inference rather than a rejection. What do you think?

Field ordering

  • test_consistent_order

The test_consistent_order test case seems like it exposes an inconsistency between infer_json_schema and infer_json_schema_from_iterator, but I think the reality is more subtle than that.

The latter function consumes an iterator of serde_json::Values, which by default store object fields in a BTreeMap, which forces them into alphabetical order. However, if the preserve_order feature is enabled, they are instead stored in an IndexMap, which preserves whatever order the fields were inserted in.

So, whether infer_json_schema is implemented to preserve the source order of fields, or sorts them alphabetically, it's still going to disagree with infer_json_schema_from_iterator in some cases. In light of that, I think preserving the source ordering is the better approach.

Duplicate fields

  • test_duplicate_fields

This is a tricky case to consider because the input in question is malformed and ambiguous, so there's an argument here that the best thing to do is just reject the input. Let me know your thoughts.

Other cases

  • test_complex_null

I agree that this is a bug in the original code that my PR fixes.

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

Labels

arrow Changes to the arrow crate arrow-json

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants