Skip to content

bench: add string, Map<Utf8, Int64> and nullable row-to-columnar cases (#7080) - #7201

Open
HarshilMital wants to merge 1 commit into
apache:mainfrom
HarshilMital:bench/7080-row-columnar
Open

HarshilMital wants to merge 1 commit into
apache:mainfrom
HarshilMital:bench/7080-row-columnar

Conversation

@HarshilMital

Copy link
Copy Markdown

Which issue does this PR close?

Part of #7080.

Rationale for this change

row_columnar only covered Int64 primitives, structs, List<Int64> and Map<Int64, Int64>, with no nulls. Strings are the most common shuffled type and take a different path (offset/size pointers into the variable-length region, and an optional dictionary builder); map keys are often strings; and real data has nulls. List cases are left out on purpose: #6855 asks that the list_conversion/elements_100 anomaly be understood before new List cases are added here.

What changes are included in this PR?

All through process_sorted_row_partition:

  • string_conversion: 10 Utf8 columns.
    • plain: prefer_dictionary_ratio 1.0.
    • dictionary: ratio 10.0 (Comet's default) over 16 distinct values per column, so the output stays Dictionary(Int32, Utf8).
    • dictionary_fallback: ratio 10.0 over distinct values, so the column is built as a dictionary and cast back to plain, which is what a high-cardinality string column costs under the default.
  • map_utf8_conversion: Map<Utf8, Int64> at 10 and 100 entries, the same grid as map_conversion.
  • nullable_conversion: 100 Int64 fields with 10% null, and Map<Int64, Int64> with 10% of values null at 10 and 100 entries. Nulls fall by a hash of row and position.
  • New UnsafeRow builders: build_string_row, build_utf8_map_row (string slots relative to the key array start, as SparkUnsafeObject::get_string reads them) and build_primitive_row.
  • run_benchmark takes prefer_dictionary_ratio instead of hardcoding 1.0, and no longer calls set_not_null_at on every field (every builder already zeroes its bitset, and clearing would erase the new null bits). build_map_row takes a row offset and a value null fraction; the existing map cases pass (0, 0.0), which reproduces their previous bytes exactly.

Results

Machine: 8 vCPU Arm Neoverse-V2 (aarch64), 31 GiB RAM, rustc 1.99.1
Commit: d66cbba. Median with Criterion's 95% interval.

Reproduce from native/:

cargo bench -p datafusion-comet-shuffle --bench row_columnar -- 'string_conversion|map_utf8_conversion|nullable_conversion' --save-baseline main
Group Case 1000 rows 10000 rows
string_conversion plain_cols_10 436.39 µs 3.72 ms
string_conversion dictionary_cols_10 243.50 µs 1.71 ms
string_conversion dictionary_fallback_cols_10 618.75 µs 5.32 ms
map_utf8_conversion entries_10 432.81 µs 3.99 ms
map_utf8_conversion entries_100 3.70 ms 36.06 ms
nullable_conversion cols_100_null_10pct 2.79 ms 28.20 ms
nullable_conversion map_entries_10_value_null_10pct 473.40 µs 3.81 ms
nullable_conversion map_entries_100_value_null_10pct 3.23 ms 33.94 ms

Under the default ratio, a high-cardinality string column costs 43% more than building it plain (5.32 ms against 3.72 ms at 10,000 rows); a low-cardinality one is 54% cheaper (1.71 ms). Every case scales about linearly from 1,000 to 10,000 rows. All intervals are within ±0.1%.

How are these changes tested?

This PR only adds benchmark code under native/shuffle/benches/; no production code changes.

  • cargo fmt --all -- --check and cargo clippy -p datafusion-comet-shuffle --all-targets -- -D warnings pass.
  • cargo bench -p datafusion-comet-shuffle --bench row_columnar --no-run builds.
  • Every new case runs once in Criterion's test mode: cargo bench -p datafusion-comet-shuffle --bench row_columnar -- --test 'string_conversion|map_utf8_conversion|nullable_conversion'.
  • Before timing, every new fixture is run through process_sorted_row_partition, read back with read_ipc_compressed, and compared value for value (null for null, and output type for the dictionary cases). Corrupting a string pointer locally makes this panic before any timing.
  • The whole row_columnar bench (50 cases, including the sorted-spill cases) also runs in test mode, since run_benchmark and build_map_row changed.

apache#7080)

Part of apache#7080: the "Row to Arrow" item (strings, Map<Utf8, _> and
nullable data). List cases are left out on purpose: apache#6855 asks that
the list_conversion/elements_100 anomaly be understood before any
new List case is added here.

What's added to row_columnar (all through process_sorted_row_partition):

- string_conversion: 10 Utf8 columns at 1000 and 10000 rows.
  - plain: prefer_dictionary_ratio 1.0, StringBuilder.
  - dictionary: ratio 10.0 (Comet's default) over 16 distinct values
    per column, so the output stays Dictionary(Int32, Utf8).
  - dictionary_fallback: ratio 10.0 over distinct values, so the
    column is built as a dictionary and cast back to plain, which is
    what a high-cardinality string column costs under the default.
- map_utf8_conversion: Map<Utf8, Int64> at 10 and 100 entries and 1000
  and 10000 rows, the same grid as map_conversion so the two compare
  directly. Keys go through the string arm of the UnsafeArrayData
  reader, against the bulk slice copy that Int64 keys take.
- nullable_conversion:
  - cols_100_null_10pct: 100 Int64 fields with 10% null.
  - map_entries_{10,100}_value_null_10pct: Map<Int64, Int64> with 10%
    of values null. At 10 entries maps mix the plain copy and the
    per-element loop; at 100 nearly every map takes the copy plus a
    validity buffer built from the null bitset (64 elements and up).
  Nulls fall by a hash of (row, position), so they neither line up nor
  recur at a fixed period.

All values vary per row and every row is built separately.

Fixture builders:
- build_string_row: offset/size pointers in the fixed region, bytes
  8-byte aligned in the variable region.
- build_utf8_map_row: key array as [count][null bitset][8-byte
  offset/size slots][8-byte aligned bytes], offsets relative to the key
  array start, as SparkUnsafeObject::get_string reads them.
- build_primitive_row: Int64 fields with a null fraction.

Helper changes:
- run_benchmark takes prefer_dictionary_ratio instead of hardcoding
  1.0; existing cases pass 1.0.
- run_benchmark no longer calls set_not_null_at on every field. Every
  builder already zeroes its bitset, and clearing would erase the
  nullable fixtures' null bits.
- build_map_row takes a row offset and a value null fraction; the
  existing map cases pass (0, 0.0), which reproduces their previous
  bytes exactly.

Fixture check: before timing, every new fixture is run through
process_sorted_row_partition, read back with read_ipc_compressed, and
compared value for value (and null for null, and output type for the
dictionary cases) against what was written.

Reproduce on the VM (from native/):

    cargo bench -p datafusion-comet-shuffle --bench row_columnar -- 'string_conversion|map_utf8_conversion|nullable_conversion' --save-baseline main

Results: TODO (VM)
@github-actions github-actions Bot added the area:shuffle Shuffle (JVM and native) label Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:shuffle Shuffle (JVM and native)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant