This is an automated email from the ASF dual-hosted git repository.

etseidl pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 87c7c3d653 bench(parquet): cover nullable and repeated large-value 
writes (#10561)
87c7c3d653 is described below

commit 87c7c3d6539b347d83878c943b25688b6305200e
Author: Adrian Garcia Badaracco <[email protected]>
AuthorDate: Tue Aug 11 18:24:22 2026 -0400

    bench(parquet): cover nullable and repeated large-value writes (#10561)
    
    ## Why
    
    Every large-value benchmark in `arrow_writer` builds its array with
    `StringArray::from_iter_values`. With no nulls present,
    `RecordBatch::try_from_iter` marks the field non-nullable, the column
    has `max_def_level == 0`, and its definition levels are absent.
    
    The writer resolves an absent-level chunk's value count in O(1) and
    never inspects levels, so **nothing here covers a nullable column whose
    values exceed `data_page_size_limit`** — the path that decides how many
    values share a data page, and on `DELTA_BYTE_ARRAY` whether prefix
    deduplication survives at all. The repeated case is a third level shape,
    also uncovered: records cannot span data pages, so mini-batches must
    step whole records.
    
    ## What
    
    One nullable case, then five that each vary a single property of it, so
    a movement can be attributed rather than guessed at:
    
    | group | varies | |
    | --- | --- | --- |
    | `large_string_shared_prefix_nullable` | — | 2 MiB values, one null in
    16 |
    | `..._nullable_dense` | null density → one in two | ratio is exactly
    two levels per value, so window arithmetic is exact — the case that
    should *not* move |
    | `..._nullable_trailing` | null placement, count fixed | a run of nulls
    at the end leaves every earlier window holding values only |
    | `large_string_distinct_nullable` | prefix | removes what deduplication
    has to work with |
    | `medium_string_shared_prefix_nullable` | value size → 256 KiB |
    several values share a page budget instead of one overrunning it |
    | `..._shared_prefix_list` | level shape | repeated column, records
    cannot span pages |
    
    `PLAIN` is measured where page count alone drives the result and omitted
    where it would restate a neighbour: it never reads the previous value,
    so prefix, null placement and value size do not change its per-page
    work.
    
    ## Validation
    
    Run against #10554, which changes exactly this path, as base → branch →
    base on an idle machine so the two base passes give a per-benchmark
    noise floor. Base is #10505's head, so the effect is #10554 alone.
    
    | benchmark | effect | noise floor |
    | --- | --- | --- |
    | `..._nullable_trailing/delta_byte_array` | **−27.8%** | 1.2% |
    | `..._nullable/plain` | **+27.6%** | 2.1% |
    | `..._nullable/delta_byte_array` | +7.6% | 1.0% |
    | `..._nullable_dense/delta_byte_array` | **−0.2%** | 0.1% |
    | `large_string_distinct_nullable/delta_byte_array` | +3.1% | 3.1% |
    | `medium_string_shared_prefix_nullable/delta_byte_array` | +1.3% | 2.9%
    |
    | `..._shared_prefix_list/delta_byte_array` | +1.3% | 0.9% |
    | `list_struct_with_list/*` *(untouched control)* | −0.1% to +0.8% |
    0.1–2.4% |
    
    The design holds up: the two cases built to move do, the case built as a
    control lands at −0.2% against a 0.1% floor, and the cases whose axis
    should not matter stay inside their own noise.
    
    Time alone understates what is happening, so the same batches written to
    a file:
    
    | case | #10505 | #10505 + #10554 |
    | --- | --- | --- |
    | `uniform_1in16` / `DELTA_BYTE_ARRAY` | 16 MiB | **2 MiB** |
    | `trailing_8` / `DELTA_BYTE_ARRAY` | 120 MiB | **2 MiB** |
    | either / `PLAIN` | 240 MiB | 240 MiB |
    
    Which is why the throughput numbers split the way they do. `_trailing`
    gets faster because 118 MiB of writing disappears; the uniform case pays
    a small amount because its output was already close to deduplicated and
    value-exact windows roughly double the mini-batch count; `PLAIN` pays
    the most because it doubles pages for no reduction in output at all.
    
    ## Notes
    
    Benchmark-only; no library code touched.
    
    CI clippy is currently red on `main` for an unrelated reason —
    `arrow-arith/src/numeric.rs:756` trips `collapsible_if`, from #10409 —
    so this PR's Clippy job will fail until that lands a fix. `cargo clippy
    -p parquet --all-features --benches -- -D warnings` is clean.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
---
 parquet/benches/arrow_writer.rs | 167 ++++++++++++++++++++++++++++++++++++++--
 1 file changed, 162 insertions(+), 5 deletions(-)

diff --git a/parquet/benches/arrow_writer.rs b/parquet/benches/arrow_writer.rs
index 93c9b8ebdc..73dc6fae79 100644
--- a/parquet/benches/arrow_writer.rs
+++ b/parquet/benches/arrow_writer.rs
@@ -31,6 +31,7 @@ use std::sync::Arc;
 use arrow::datatypes::*;
 use arrow::util::bench_util::{create_f16_array, create_f32_array, 
create_f64_array};
 use arrow::{record_batch::RecordBatch, util::data_gen::*};
+use arrow_array::builder::{ListBuilder, StringBuilder};
 use arrow_array::{RecordBatchOptions, StringArray};
 use parquet::errors::Result;
 use parquet::file::properties::{CdcOptions, WriterProperties, WriterVersion};
@@ -179,6 +180,83 @@ fn create_large_string_distinct_bench_batch(size: usize, 
value_size: usize) -> R
     Ok(RecordBatch::try_from_iter([("col", array)])?)
 }
 
+/// Where nulls fall in a generated batch, for
+/// [`create_large_string_nullable_bench_batch`].
+#[derive(Clone, Copy)]
+enum NullPattern {
+    /// One null every `n` rows, spread evenly.
+    Every(usize),
+    /// `n` nulls in a single run at the end of the batch.
+    Trailing(usize),
+}
+
+/// `size` rows of `value_size`-byte strings with nulls, sharing a long common
+/// prefix when `shared_prefix` is set and differing from their first byte
+/// otherwise.
+///
+/// Nullability is the point. The non-null large-value batches leave the
+/// column's definition levels absent, so the writer's byte-budget
+/// sub-batching resolves a chunk's value count in O(1) and never inspects
+/// levels. Nulls put it on the general path, where the number of values that
+/// share a data page is derived from the chunk's level-to-value ratio.
+///
+/// That ratio is why the density levels chosen at the call sites are not
+/// simply "few" and "many". Where a single value already fills the page
+/// budget, the derived window spans `ceil(levels / values)` levels, so it
+/// covers about `levels / values` values instead of one — an overshoot that
+/// is largest when nulls are *sparse* and disappears exactly when the ratio
+/// is a whole number, as at one-null-in-two.
+fn create_large_string_nullable_bench_batch(
+    size: usize,
+    value_size: usize,
+    shared_prefix: bool,
+    nulls: NullPattern,
+) -> Result<RecordBatch> {
+    let filler = "x".repeat(value_size - 8);
+    let is_null = |i: usize| match nulls {
+        NullPattern::Every(n) => i % n == n - 1,
+        NullPattern::Trailing(n) => i >= size - n,
+    };
+    let array = Arc::new(StringArray::from_iter((0..size).map(|i| {
+        (!is_null(i)).then(|| {
+            if shared_prefix {
+                format!("{filler}{i:08}")
+            } else {
+                format!("{i:08}{filler}")
+            }
+        })
+    }))) as _;
+    Ok(RecordBatch::try_from_iter([("col", array)])?)
+}
+
+/// `size` records of `values_per_record` strings of `value_size` bytes,
+/// sharing a long common prefix.
+///
+/// A repeated column is the third level shape the writer sub-batches against,
+/// after absent and flat-nullable levels. Records cannot span data pages, so
+/// mini-batches must step whole records; with values this large a single
+/// record overruns the page limit on its own.
+fn create_list_large_string_bench_batch(
+    size: usize,
+    values_per_record: usize,
+    value_size: usize,
+) -> Result<RecordBatch> {
+    let prefix = "x".repeat(value_size - 8);
+    let mut builder = ListBuilder::new(StringBuilder::new());
+    for i in 0..size {
+        for j in 0..values_per_record {
+            builder
+                .values()
+                .append_value(format!("{prefix}{:08}", i * values_per_record + 
j));
+        }
+        builder.append(true);
+    }
+    Ok(RecordBatch::try_from_iter([(
+        "col",
+        Arc::new(builder.finish()) as _,
+    )])?)
+}
+
 /// `size` rows of `value_size`-byte strings sharing their first
 /// `shared_bytes` bytes and differing thereafter — the realistic sorted-column
 /// case (paths, URLs, keys), where prefix deduplication saves part of each
@@ -813,9 +891,88 @@ fn bench_delta_byte_array_writers(c: &mut Criterion) {
         .set_encoding(Encoding::DELTA_BYTE_ARRAY)
         .build();
 
-    for (batch_name, batch) in [
-        ("large_string_shared_prefix", &shared),
-        ("large_string_distinct", &distinct),
+    // Nullable and repeated counterparts. Each varies one property of the
+    // first nullable case, so a movement can be attributed to that property
+    // rather than to some combination:
+    //
+    // * `_dense` changes only the null density, to a ratio of exactly two
+    //   levels per value. Deriving a window from that ratio is exact, so this
+    //   case is the one where sub-batching arithmetic cannot go wrong — it
+    //   should stay flat when the others move.
+    // * `_trailing` changes only where the nulls sit, keeping the count. A
+    //   run of nulls at the end leaves every window before it holding values
+    //   only, which is the worst placement for `DELTA_BYTE_ARRAY`.
+    // * `distinct_nullable` changes only the prefix, removing what
+    //   deduplication has to work with.
+    // * `medium_string_*` changes only the value size, to a size where
+    //   several values share a page budget rather than one overrunning it.
+    // * `_list` changes only the level shape, to a repeated column. Records
+    //   cannot span pages, so this one is a control: the writer's output for
+    //   it is byte for byte identical across the changes it is used to judge.
+    //
+    // `PLAIN` is measured wherever page count alone drives the result, and
+    // omitted where it would only restate a neighbouring case: it does not
+    // read the previous value, so prefix, null placement and value size do
+    // not change its per-page work.
+    let nullable = create_large_string_nullable_bench_batch(
+        128,
+        2 * 1024 * 1024,
+        true,
+        NullPattern::Every(16),
+    )
+    .unwrap();
+    let nullable_dense =
+        create_large_string_nullable_bench_batch(128, 2 * 1024 * 1024, true, 
NullPattern::Every(2))
+            .unwrap();
+    let nullable_trailing = create_large_string_nullable_bench_batch(
+        128,
+        2 * 1024 * 1024,
+        true,
+        NullPattern::Trailing(8),
+    )
+    .unwrap();
+    let nullable_distinct = create_large_string_nullable_bench_batch(
+        128,
+        2 * 1024 * 1024,
+        false,
+        NullPattern::Every(16),
+    )
+    .unwrap();
+    // 256 KiB against the 1 MiB default limit: several values to a page.
+    let nullable_medium =
+        create_large_string_nullable_bench_batch(1024, 256 * 1024, true, 
NullPattern::Every(16))
+            .unwrap();
+    // 4 values per record, so one record is ~8 MiB and cannot be split.
+    let list = create_list_large_string_bench_batch(32, 4, 2 * 1024 * 
1024).unwrap();
+
+    let both: &[(&str, &WriterProperties)] = &[("plain", &plain), 
("delta_byte_array", &delta)];
+    let delta_only: &[(&str, &WriterProperties)] = &[("delta_byte_array", 
&delta)];
+
+    for (batch_name, batch, props) in [
+        ("large_string_shared_prefix", &shared, both),
+        ("large_string_distinct", &distinct, both),
+        ("large_string_shared_prefix_nullable", &nullable, both),
+        (
+            "large_string_shared_prefix_nullable_dense",
+            &nullable_dense,
+            delta_only,
+        ),
+        (
+            "large_string_shared_prefix_nullable_trailing",
+            &nullable_trailing,
+            delta_only,
+        ),
+        (
+            "large_string_distinct_nullable",
+            &nullable_distinct,
+            delta_only,
+        ),
+        (
+            "medium_string_shared_prefix_nullable",
+            &nullable_medium,
+            delta_only,
+        ),
+        ("large_string_shared_prefix_list", &list, delta_only),
     ] {
         let mut group = c.benchmark_group(batch_name);
         group.throughput(Throughput::Bytes(
@@ -826,8 +983,8 @@ fn bench_delta_byte_array_writers(c: &mut Criterion) {
                 .sum(),
         ));
 
-        for (prop_name, prop) in [("plain", &plain), ("delta_byte_array", 
&delta)] {
-            group.bench_function(prop_name, |b| {
+        for (prop_name, prop) in props {
+            group.bench_function(*prop_name, |b| {
                 write_batch_with_option(b, batch, 
Some((*prop).clone())).unwrap()
             });
         }

Reply via email to