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

lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new c3290904 feat(benchmark): add format-level Parquet read/write 
micro-benchmarks (#232)
c3290904 is described below

commit c3290904d46b5bb876fa404f52192aa9de8dae23
Author: Nicholas Jiang <[email protected]>
AuthorDate: Wed Aug 26 17:22:32 2026 +0800

    feat(benchmark): add format-level Parquet read/write micro-benchmarks (#232)
---
 benchmark/CMakeLists.txt                    |   31 +
 benchmark/parquet_format_benchmark.cpp      | 1444 +++++++++++++++++++++++++++
 benchmark/parquet_format_benchmark_test.cpp |  533 ++++++++++
 docs/source/examples/benchmark.rst          |  112 ++-
 4 files changed, 2116 insertions(+), 4 deletions(-)

diff --git a/benchmark/CMakeLists.txt b/benchmark/CMakeLists.txt
index 375e9896..0d4dc28c 100644
--- a/benchmark/CMakeLists.txt
+++ b/benchmark/CMakeLists.txt
@@ -63,6 +63,20 @@ if(PAIMON_BUILD_BENCHMARKS)
                          ${PAIMON_BENCHMARK_LINK_TOOLCHAIN}
                          EXTRA_INCLUDES
                          ${CMAKE_SOURCE_DIR})
+
+    add_paimon_benchmark(parquet_format_benchmark
+                         SOURCES
+                         parquet_format_benchmark.cpp
+                         STATIC_LINK_LIBS
+                         arrow
+                         parquet
+                         ${PAIMON_BENCHMARK_STATIC_LINK_LIBS}
+                         test_utils_static
+                         Threads::Threads
+                         ${PAIMON_BENCHMARK_PLATFORM_LINK_LIBS}
+                         ${PAIMON_BENCHMARK_LINK_TOOLCHAIN}
+                         EXTRA_INCLUDES
+                         ${CMAKE_SOURCE_DIR})
 endif()
 
 if(PAIMON_BUILD_TESTS)
@@ -74,4 +88,21 @@ if(PAIMON_BUILD_TESTS)
                     STATIC_LINK_LIBS
                     paimon_shared
                     ${GTEST_LINK_TOOLCHAIN})
+
+    # Guards the format-layer assumptions parquet_format_benchmark.cpp is 
built on. The benchmark
+    # itself is only compiled under PAIMON_BUILD_BENCHMARKS, which CI does not 
set, so this test is
+    # what keeps those assumptions covered.
+    add_paimon_test(parquet_format_benchmark_test
+                    SOURCES
+                    parquet_format_benchmark_test.cpp
+                    EXTRA_INCLUDES
+                    ${CMAKE_SOURCE_DIR}
+                    STATIC_LINK_LIBS
+                    arrow
+                    parquet
+                    paimon_shared
+                    ${PAIMON_LOCAL_FILE_SYSTEM_SHARED_LINK_LIBS}
+                    ${PAIMON_PARQUET_FILE_FORMAT_STATIC_LINK_LIBS}
+                    test_utils_static
+                    ${GTEST_LINK_TOOLCHAIN})
 endif()
diff --git a/benchmark/parquet_format_benchmark.cpp 
b/benchmark/parquet_format_benchmark.cpp
new file mode 100644
index 00000000..e8d3e002
--- /dev/null
+++ b/benchmark/parquet_format_benchmark.cpp
@@ -0,0 +1,1444 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Format-level micro-benchmarks for the Parquet reader and writer. These drive
+// ParquetWriterBuilder / ParquetFileBatchReader directly, so a format-layer 
change can be
+// attributed without the catalog lookup, split planning, merge/sort and 
commit that the
+// table-level read_write_benchmark includes. Each case comment says what that 
case answers.
+//
+// Every axis - type, cardinality, null density, batch size, encoding, 
selectivity - is swept on
+// its own rather than as a combined matrix, because the point is attributing 
one change rather
+// than describing a workload.
+//
+// Data is generated outside the timed region and on Arrow's default pool, 
because the writer
+// cuts a new row group once its own pool crosses 
parquet.writer.max.memory.use.
+//
+// IO goes through the local FileSystem into a temporary directory - the 
project has no in-memory
+// FileSystem - so absolute numbers are only meaningful relative to each other.
+
+#include <algorithm>
+#include <atomic>
+#include <chrono>
+#include <cstdint>
+#include <functional>
+#include <map>
+#include <memory>
+#include <mutex>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/api.h"
+#include "arrow/c/abi.h"
+#include "arrow/c/bridge.h"
+#include "arrow/c/helpers.h"
+#include "arrow/util/bit_util.h"
+#include "benchmark/benchmark.h"
+#include "paimon/common/utils/arrow/arrow_input_stream_adapter.h"
+#include "paimon/common/utils/arrow/mem_utils.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/checked_cast.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/defs.h"
+#include "paimon/format/format_writer.h"
+#include "paimon/format/parquet/parquet_field_id_converter.h"
+#include "paimon/format/parquet/parquet_file_batch_reader.h"
+#include "paimon/format/parquet/parquet_format_defs.h"
+#include "paimon/format/parquet/parquet_writer_builder.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/metrics.h"
+#include "paimon/predicate/literal.h"
+#include "paimon/predicate/predicate.h"
+#include "paimon/predicate/predicate_builder.h"
+#include "paimon/reader/batch_reader.h"
+#include "paimon/result.h"
+#include "paimon/status.h"
+#include "paimon/testing/utils/testharness.h"
+#include "paimon/utils/roaring_bitmap32.h"
+
+namespace {
+
+using ::paimon::ArrowInputStreamAdapter;
+using ::paimon::BatchReader;
+using ::paimon::FieldType;
+using ::paimon::FileStatus;
+using ::paimon::FileSystem;
+using ::paimon::FormatWriter;
+using ::paimon::InputStream;
+using ::paimon::Literal;
+using ::paimon::OutputStream;
+using ::paimon::PathUtil;
+using ::paimon::Predicate;
+using ::paimon::PredicateBuilder;
+using ::paimon::Result;
+using ::paimon::RoaringBitmap32;
+using ::paimon::Status;
+using ::paimon::parquet::ParquetFieldIdConverter;
+using ::paimon::parquet::ParquetFileBatchReader;
+using ::paimon::parquet::ParquetWriterBuilder;
+
+constexpr int64_t kRowsPerFile = 100'000;
+constexpr int64_t kRowsPerBatch = 10'000;
+constexpr int32_t kReadBatchSize = 4096;
+constexpr int32_t kWriteBatchSize = 1024;
+// Small enough that a 100K-row file spans many pages, so page-level pruning 
has something to
+// prune. Arrow's page limit is byte-based; a row-count limit is not available 
yet.
+constexpr int64_t kPageSizeBytes = 64 * 1024;
+// Four row groups per read fixture, so row-group pruning and page pruning are 
both in play.
+constexpr int64_t kRowGroupLength = 25'000;
+constexpr int64_t kStringCardinality = 1'000;
+// Few enough distinct values that arrow keeps the column dictionary-encoded 
for the whole file,
+// which is the shape the wide-schema case wants: per-column work small, 
per-batch cost visible.
+constexpr int64_t kLowStringCardinality = 10;
+constexpr int32_t kVectorDimension = 16;
+constexpr int32_t kListLength = 4;
+// Same as kListLength, so the MAP and LIST cases differ only in the extra key 
leaf.
+constexpr int32_t kMapEntries = kListLength;
+constexpr char kDefaultCompression[] = "zstd";
+// Nulls are placed over a 100-row window, so a requested density is exact, 
not statistical.
+constexpr int64_t kNullWindow = 100;
+
+std::shared_ptr<arrow::Field> MakeField(const std::string& name,
+                                        const 
std::shared_ptr<arrow::DataType>& type,
+                                        int32_t field_id) {
+    return arrow::field(name, type,
+                        
arrow::KeyValueMetadata::Make({ParquetFieldIdConverter::PARQUET_FIELD_ID},
+                                                      
{std::to_string(field_id)}));
+}
+
+std::shared_ptr<arrow::DataType> StructColumnType() {
+    return arrow::struct_({arrow::field("a", arrow::int64()), 
arrow::field("b", arrow::utf8())});
+}
+
+std::shared_ptr<arrow::DataType> ListColumnType() {
+    return arrow::list(arrow::int64());
+}
+
+std::shared_ptr<arrow::DataType> VectorColumnType() {
+    return arrow::fixed_size_list(arrow::field("element", arrow::float32(), 
/*nullable=*/false),
+                                  kVectorDimension);
+}
+
+std::shared_ptr<arrow::DataType> MapColumnType() {
+    return arrow::map(arrow::utf8(), arrow::int64());
+}
+
+std::shared_ptr<arrow::DataType> DictionaryStringType() {
+    return arrow::dictionary(arrow::int32(), arrow::utf8());
+}
+
+std::shared_ptr<arrow::DataType> DictionaryInt32Type() {
+    return arrow::dictionary(arrow::int32(), arrow::int32());
+}
+
+// The three-column file the flat read cases scan. `id` is ordered so a range 
predicate maps
+// onto a contiguous row range, which is what makes page-index pruning 
measurable.
+std::shared_ptr<arrow::Schema> FlatSchema() {
+    return arrow::schema({MakeField("id", arrow::int64(), 0), 
MakeField("name", arrow::utf8(), 1),
+                          MakeField("amount", arrow::decimal128(18, 4), 2)});
+}
+
+std::shared_ptr<arrow::Schema> DecimalSchema(int32_t precision) {
+    return arrow::schema({MakeField("amount", arrow::decimal128(precision, 4), 
0)});
+}
+
+std::shared_ptr<arrow::Schema> DoubleSchema() {
+    return arrow::schema({MakeField("value", arrow::float64(), 0)});
+}
+
+std::shared_ptr<arrow::Schema> NestedSchema() {
+    return arrow::schema(
+        {MakeField("id", arrow::int64(), 0), MakeField("info", 
StructColumnType(), 1),
+         MakeField("tags", ListColumnType(), 2), MakeField("embedding", 
VectorColumnType(), 3),
+         MakeField("attrs", MapColumnType(), 4)});
+}
+
+// A VECTOR is stored as a Parquet LIST and the reader hands back the file's 
own types, so a
+// format-level read asks for the physical type; VectorFileBatchReader 
restores the view above.
+std::shared_ptr<arrow::Schema> NestedReadSchema() {
+    return arrow::schema({MakeField("id", arrow::int64(), 0),
+                          MakeField("info", StructColumnType(), 1),
+                          MakeField("tags", ListColumnType(), 2),
+                          MakeField("embedding",
+                                    arrow::list(arrow::field("element", 
arrow::float32(),
+                                                             
/*nullable=*/false)),
+                                    3),
+                          MakeField("attrs", MapColumnType(), 4)});
+}
+
+Result<std::shared_ptr<arrow::Array>> MakeInt64Column(int64_t num_rows, 
int64_t offset) {
+    arrow::Int64Builder builder;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Reserve(num_rows));
+    for (int64_t i = 0; i < num_rows; ++i) {
+        builder.UnsafeAppend(offset + i);
+    }
+    std::shared_ptr<arrow::Array> array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Finish(&array));
+    return array;
+}
+
+Result<std::shared_ptr<arrow::Array>> MakeDoubleColumn(int64_t num_rows, 
int64_t offset) {
+    arrow::DoubleBuilder builder;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Reserve(num_rows));
+    for (int64_t i = 0; i < num_rows; ++i) {
+        builder.UnsafeAppend(static_cast<double>(offset + i) * 1.5);
+    }
+    std::shared_ptr<arrow::Array> array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Finish(&array));
+    return array;
+}
+
+Result<std::shared_ptr<arrow::Array>> MakeBooleanColumn(int64_t num_rows, 
int64_t offset) {
+    arrow::BooleanBuilder builder;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Reserve(num_rows));
+    for (int64_t i = 0; i < num_rows; ++i) {
+        builder.UnsafeAppend(((offset + i) & 1) == 0);
+    }
+    std::shared_ptr<arrow::Array> array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Finish(&array));
+    return array;
+}
+
+// Low cardinality is what arrow dictionary-encodes; high cardinality falls 
back to plain.
+Result<std::shared_ptr<arrow::Array>> MakeStringColumn(int64_t num_rows, 
int64_t offset,
+                                                       int64_t cardinality) {
+    arrow::StringBuilder builder;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Reserve(num_rows));
+    for (int64_t i = 0; i < num_rows; ++i) {
+        const int64_t value = (offset + i) % cardinality;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Append("value_" + 
std::to_string(value)));
+    }
+    std::shared_ptr<arrow::Array> array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Finish(&array));
+    return array;
+}
+
+// INT32 cycling through `cardinality` distinct values, the flat control for
+// BM_ParquetWrite_DictionaryInt32: same logical values, same cardinality, 
same width, so the
+// delta between the two is the dictionary materialization alone.
+Result<std::shared_ptr<arrow::Array>> MakeInt32Column(int64_t num_rows, 
int64_t offset,
+                                                      int64_t cardinality) {
+    arrow::Int32Builder builder;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Reserve(num_rows));
+    for (int64_t i = 0; i < num_rows; ++i) {
+        builder.UnsafeAppend(static_cast<int32_t>(((offset + i) % cardinality) 
* 7));
+    }
+    std::shared_ptr<arrow::Array> array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Finish(&array));
+    return array;
+}
+
+// Dictionary-encoded input: `values->length()` distinct values behind an 
int32 index array. Arrow
+// hands the indices straight to Parquet when the value type is binary-like
+// (DictionaryDirectWriteSupported) and densifies them otherwise, so the value 
type alone decides
+// whether the writer materializes anything.
+Result<std::shared_ptr<arrow::Array>> MakeDictionaryColumn(
+    const std::shared_ptr<arrow::Array>& values, int64_t num_rows, int64_t 
offset) {
+    const int64_t cardinality = values->length();
+    arrow::Int32Builder index_builder;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(index_builder.Reserve(num_rows));
+    for (int64_t i = 0; i < num_rows; ++i) {
+        index_builder.UnsafeAppend(static_cast<int32_t>((offset + i) % 
cardinality));
+    }
+    std::shared_ptr<arrow::Array> indices;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(index_builder.Finish(&indices));
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> array,
+                                      
arrow::DictionaryArray::FromArrays(indices, values));
+    return array;
+}
+
+// STRING values: binary-like, so arrow can write the indices directly.
+Result<std::shared_ptr<arrow::Array>> MakeDictionaryStringColumn(int64_t 
num_rows, int64_t offset,
+                                                                 int64_t 
cardinality) {
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> values,
+                           MakeStringColumn(cardinality, /*offset=*/0, 
cardinality));
+    return MakeDictionaryColumn(values, num_rows, offset);
+}
+
+// INT32 values: is_base_binary_like excludes them, so arrow densifies before 
writing. The
+// dictionary holds the same `i * 7` values MakeInt32Column emits inline, so 
the two are directly
+// comparable.
+Result<std::shared_ptr<arrow::Array>> MakeDictionaryInt32Column(int64_t 
num_rows, int64_t offset,
+                                                                int64_t 
cardinality) {
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> values,
+                           MakeInt32Column(cardinality, /*offset=*/0, 
cardinality));
+    return MakeDictionaryColumn(values, num_rows, offset);
+}
+
+// Precision drives the Parquet physical type: <= 9 is INT32, <= 18 is INT64, 
larger is
+// FIXED_LEN_BYTE_ARRAY, and the three take different transfer paths on read.
+Result<std::shared_ptr<arrow::Array>> MakeDecimalColumn(int64_t num_rows, 
int64_t offset,
+                                                        int32_t precision, 
int32_t scale) {
+    arrow::Decimal128Builder builder(arrow::decimal128(precision, scale));
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Reserve(num_rows));
+    for (int64_t i = 0; i < num_rows; ++i) {
+        
PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Append(arrow::Decimal128(offset + i)));
+    }
+    std::shared_ptr<arrow::Array> array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Finish(&array));
+    return array;
+}
+
+Result<std::shared_ptr<arrow::Array>> MakeStructArray(
+    const arrow::FieldVector& fields, const 
std::vector<std::shared_ptr<arrow::Array>>& columns) {
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::StructArray> 
array,
+                                      arrow::StructArray::Make(columns, 
fields));
+    return paimon::checked_pointer_cast<arrow::Array>(array);
+}
+
+Result<std::shared_ptr<arrow::Array>> MakeStructColumn(int64_t num_rows, 
int64_t offset) {
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> a, 
MakeInt64Column(num_rows, offset));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> b,
+                           MakeStringColumn(num_rows, offset, 
kStringCardinality));
+    return MakeStructArray(StructColumnType()->fields(), {a, b});
+}
+
+// Constant element count per row, so the delta against a flat BIGINT column 
is the levels.
+Result<std::shared_ptr<arrow::Array>> MakeListColumn(int64_t num_rows, int64_t 
offset) {
+    auto value_builder = std::make_shared<arrow::Int64Builder>();
+    arrow::ListBuilder builder(arrow::default_memory_pool(), value_builder, 
ListColumnType());
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Reserve(num_rows));
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(value_builder->Reserve(num_rows * 
kListLength));
+    for (int64_t i = 0; i < num_rows; ++i) {
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Append());
+        for (int32_t j = 0; j < kListLength; ++j) {
+            value_builder->UnsafeAppend(offset + i + j);
+        }
+    }
+    std::shared_ptr<arrow::Array> array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Finish(&array));
+    return array;
+}
+
+// The writer converts VECTOR to a Parquet LIST, so this also covers 
ParquetVectorConverter.
+Result<std::shared_ptr<arrow::Array>> MakeVectorColumn(int64_t num_rows, 
int64_t offset) {
+    auto value_builder = std::make_shared<arrow::FloatBuilder>();
+    arrow::FixedSizeListBuilder builder(arrow::default_memory_pool(), 
value_builder,
+                                        VectorColumnType());
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Reserve(num_rows));
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(value_builder->Reserve(num_rows * 
kVectorDimension));
+    for (int64_t i = 0; i < num_rows; ++i) {
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Append());
+        for (int32_t j = 0; j < kVectorDimension; ++j) {
+            value_builder->UnsafeAppend(static_cast<float>((offset + i) % 
1024) +
+                                        static_cast<float>(j));
+        }
+    }
+    std::shared_ptr<arrow::Array> array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Finish(&array));
+    return array;
+}
+
+// A Parquet MAP is a LIST of a two-field STRUCT, so against LIST the 
difference is the key leaf.
+Result<std::shared_ptr<arrow::Array>> MakeMapColumn(int64_t num_rows, int64_t 
offset,
+                                                    int32_t entries) {
+    auto key_builder = std::make_shared<arrow::StringBuilder>();
+    auto item_builder = std::make_shared<arrow::Int64Builder>();
+    arrow::MapBuilder builder(arrow::default_memory_pool(), key_builder, 
item_builder,
+                              MapColumnType());
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Reserve(num_rows));
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(key_builder->Reserve(num_rows * entries));
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(item_builder->Reserve(num_rows * entries));
+    for (int64_t i = 0; i < num_rows; ++i) {
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Append());
+        for (int32_t j = 0; j < entries; ++j) {
+            PAIMON_RETURN_NOT_OK_FROM_ARROW(key_builder->Append("key_" + 
std::to_string(j)));
+            item_builder->UnsafeAppend(offset + i + j);
+        }
+    }
+    std::shared_ptr<arrow::Array> array;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(builder.Finish(&array));
+    return array;
+}
+
+// Masks `null_pct` percent of the slots null. Doing it after the fact rather 
than in every
+// generator keeps the values identical across densities, so the delta between 
two settings is the
+// level machinery alone. Only the top level is masked: a STRUCT or LIST row, 
not its leaves.
+Result<std::shared_ptr<arrow::Array>> WithNulls(const 
std::shared_ptr<arrow::Array>& array,
+                                                int64_t offset, int64_t 
null_pct) {
+    if (null_pct <= 0) {
+        return array;
+    }
+    // Builder output starts at slot 0, so the bitmap below can be indexed by 
position.
+    if (array->data()->offset != 0) {
+        return Status::Invalid("WithNulls expects an unsliced array");
+    }
+    const int64_t length = array->length();
+    PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+        std::shared_ptr<arrow::Buffer> validity,
+        arrow::AllocateEmptyBitmap(length, arrow::default_memory_pool()));
+    int64_t null_count = 0;
+    for (int64_t i = 0; i < length; ++i) {
+        if ((offset + i) % kNullWindow < null_pct) {
+            ++null_count;
+        } else {
+            arrow::bit_util::SetBit(validity->mutable_data(), i);
+        }
+    }
+    std::shared_ptr<arrow::ArrayData> data = array->data()->Copy();
+    data->buffers[0] = std::move(validity);
+    data->SetNullCount(null_count);
+    return arrow::MakeArray(data);
+}
+
+using BatchFactory = std::function<Result<std::shared_ptr<arrow::Array>>(
+    const std::shared_ptr<arrow::Schema>& schema, int64_t offset, int64_t 
rows)>;
+
+using ColumnFactory =
+    std::function<Result<std::shared_ptr<arrow::Array>>(int64_t rows, int64_t 
offset)>;
+
+ColumnFactory NullableColumnFactory(const ColumnFactory& make_column, int64_t 
null_pct) {
+    return [make_column, null_pct](int64_t rows,
+                                   int64_t offset) -> 
Result<std::shared_ptr<arrow::Array>> {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> column, 
make_column(rows, offset));
+        return WithNulls(column, offset, null_pct);
+    };
+}
+
+// Lifts a one-column generator into a batch factory for a one-field schema.
+BatchFactory SingleColumnBatch(const ColumnFactory& make_column) {
+    return [make_column](const std::shared_ptr<arrow::Schema>& schema, int64_t 
offset,
+                         int64_t rows) -> 
Result<std::shared_ptr<arrow::Array>> {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> column, 
make_column(rows, offset));
+        return MakeStructArray(schema->fields(), {column});
+    };
+}
+
+Result<std::shared_ptr<arrow::Array>> MakeNullableFlatBatch(
+    const std::shared_ptr<arrow::Schema>& schema, int64_t offset, int64_t 
rows, int64_t null_pct) {
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> ids, 
MakeInt64Column(rows, offset));
+    PAIMON_ASSIGN_OR_RAISE(ids, WithNulls(ids, offset, null_pct));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> names,
+                           MakeStringColumn(rows, offset, kStringCardinality));
+    PAIMON_ASSIGN_OR_RAISE(names, WithNulls(names, offset, null_pct));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> amounts,
+                           MakeDecimalColumn(rows, offset, /*precision=*/18, 
/*scale=*/4));
+    PAIMON_ASSIGN_OR_RAISE(amounts, WithNulls(amounts, offset, null_pct));
+    return MakeStructArray(schema->fields(), {ids, names, amounts});
+}
+
+Result<std::shared_ptr<arrow::Array>> MakeFlatBatch(const 
std::shared_ptr<arrow::Schema>& schema,
+                                                    int64_t offset, int64_t 
rows) {
+    return MakeNullableFlatBatch(schema, offset, rows, /*null_pct=*/0);
+}
+
+// One low-cardinality VARCHAR per field, cheap enough that a wide schema 
leaves the per-column
+// setup holding the measurement. The staggered offset keeps the columns from 
being identical.
+Result<std::shared_ptr<arrow::Array>> MakeWideBatch(const 
std::shared_ptr<arrow::Schema>& schema,
+                                                    int64_t offset, int64_t 
rows) {
+    std::vector<std::shared_ptr<arrow::Array>> columns;
+    columns.reserve(schema->num_fields());
+    for (int32_t i = 0; i < schema->num_fields(); ++i) {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> column,
+                               MakeStringColumn(rows, offset + i, 
kLowStringCardinality));
+        columns.push_back(std::move(column));
+    }
+    return MakeStructArray(schema->fields(), columns);
+}
+
+Result<std::shared_ptr<arrow::Array>> MakeNestedBatch(const 
std::shared_ptr<arrow::Schema>& schema,
+                                                      int64_t offset, int64_t 
rows) {
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> ids, 
MakeInt64Column(rows, offset));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> info, 
MakeStructColumn(rows, offset));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> tags, 
MakeListColumn(rows, offset));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> embedding, 
MakeVectorColumn(rows, offset));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> attrs,
+                           MakeMapColumn(rows, offset, kMapEntries));
+    return MakeStructArray(schema->fields(), {ids, info, tags, embedding, 
attrs});
+}
+
+// Whether every AddBatch call gets its own array or they all share one. It 
matters for dictionary
+// input: arrow compares each batch's dictionary against the previous one, so 
N distinct-but-equal
+// dictionaries and one dictionary written N times are not the same write. Only
+// BM_ParquetWrite_MemoryThreshold reuses one batch; every other case 
generates each batch
+// independently. That is about the arrays, not the values - generators that 
cycle with a period
+// dividing the batch size, such as the boolean and the low-cardinality string 
ones, produce
+// batches that are equal in value but separate objects.
+enum class BatchReuse { kFresh, kReused };
+
+Result<std::vector<std::shared_ptr<arrow::Array>>> MakeBatches(
+    const std::shared_ptr<arrow::Schema>& schema, const BatchFactory& 
make_batch,
+    int64_t rows_per_batch, int64_t total_rows = kRowsPerFile,
+    BatchReuse reuse = BatchReuse::kFresh) {
+    if (reuse == BatchReuse::kReused) {
+        // One array written N times cannot express a short final batch, and 
silently writing a
+        // full one instead would put more rows in the file than the reported 
metrics divide by.
+        if (total_rows % rows_per_batch != 0) {
+            return Status::Invalid("BatchReuse::kReused needs total_rows 
divisible by " +
+                                   std::to_string(rows_per_batch) + ", got " +
+                                   std::to_string(total_rows));
+        }
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> batch,
+                               make_batch(schema, /*offset=*/0, 
rows_per_batch));
+        return std::vector<std::shared_ptr<arrow::Array>>(total_rows / 
rows_per_batch,
+                                                          std::move(batch));
+    }
+    std::vector<std::shared_ptr<arrow::Array>> batches;
+    for (int64_t offset = 0; offset < total_rows; offset += rows_per_batch) {
+        const int64_t rows = std::min<int64_t>(rows_per_batch, total_rows - 
offset);
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> batch,
+                               make_batch(schema, offset, rows));
+        batches.push_back(std::move(batch));
+    }
+    return batches;
+}
+
+Result<int64_t> WriteParquetFile(const std::shared_ptr<FileSystem>& fs, const 
std::string& path,
+                                 const std::shared_ptr<arrow::Schema>& schema,
+                                 const 
std::vector<std::shared_ptr<arrow::Array>>& batches,
+                                 const std::map<std::string, std::string>& 
options,
+                                 const std::string& compression) {
+    ParquetWriterBuilder writer_builder(schema, kWriteBatchSize, options);
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<OutputStream> out, fs->Create(path, 
/*overwrite=*/true));
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FormatWriter> writer,
+                           writer_builder.Build(out, compression));
+    for (const auto& batch : batches) {
+        // AddBatch imports - and so consumes - the C array, so each batch 
needs a fresh export.
+        ArrowArray c_array;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*batch, &c_array));
+        PAIMON_RETURN_NOT_OK(writer->AddBatch(&c_array));
+    }
+    PAIMON_RETURN_NOT_OK(writer->Finish());
+    PAIMON_RETURN_NOT_OK(out->Close());
+    PAIMON_ASSIGN_OR_RAISE(FileStatus file_status, fs->GetFileStatus(path));
+    return file_status.GetLen();
+}
+
+struct ReadStats {
+    int64_t rows = 0;
+    // Reader-side counterpart of file size: makes pruning and skipping 
visible apart from CPU.
+    uint64_t storage_bytes = 0;
+    // Without these a filtered case shows only that it got faster, not that 
pruning is why.
+    uint64_t row_groups_total = 0;
+    uint64_t row_groups_after_filter = 0;
+    uint64_t batches = 0;
+};
+
+// Every counter read below is set unconditionally by the reader - the 
row-group pair in
+// SetReadSchema, the batch count in NextBatch - so an absent one means the 
metric moved or stopped
+// being recorded. Reporting that as a zero would hide the regression behind a 
plausible number,
+// so the error propagates and fails the case instead.
+Result<uint64_t> ReadCounter(const std::shared_ptr<paimon::Metrics>& metrics,
+                             const std::string& name) {
+    return metrics->GetCounter(name);
+}
+
+Result<ReadStats> ReadParquetFile(const std::shared_ptr<FileSystem>& fs, const 
std::string& path,
+                                  const std::shared_ptr<arrow::Schema>& 
read_schema,
+                                  const std::shared_ptr<Predicate>& predicate,
+                                  const std::optional<RoaringBitmap32>& 
selection_bitmap,
+                                  const std::map<std::string, std::string>& 
options,
+                                  int32_t batch_size,
+                                  const std::shared_ptr<arrow::MemoryPool>& 
pool) {
+    PAIMON_ASSIGN_OR_RAISE(FileStatus file_status, fs->GetFileStatus(path));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input, fs->Open(path));
+    auto in_stream = std::make_shared<ArrowInputStreamAdapter>(input, 
file_status.GetLen(), pool);
+    // Held separately so the counter outlives the adapter the reader takes 
ownership of.
+    std::shared_ptr<std::atomic<uint64_t>> storage_read_bytes = 
in_stream->StorageReadBytes();
+    PAIMON_ASSIGN_OR_RAISE(
+        std::unique_ptr<ParquetFileBatchReader> reader,
+        ParquetFileBatchReader::Create(std::move(in_stream), options, 
batch_size,
+                                       /*file_metadata=*/nullptr, 
storage_read_bytes, pool,
+                                       /*hints=*/std::nullopt));
+
+    // SetReadSchema imports the C schema and takes ownership of it.
+    ArrowSchema c_schema;
+    PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(*read_schema, 
&c_schema));
+    PAIMON_RETURN_NOT_OK(reader->SetReadSchema(&c_schema, predicate, 
selection_bitmap));
+
+    ReadStats stats;
+    while (true) {
+        PAIMON_ASSIGN_OR_RAISE(BatchReader::ReadBatch batch, 
reader->NextBatch());
+        if (BatchReader::IsEofBatch(batch)) {
+            break;
+        }
+        stats.rows += batch.first->length;
+        ArrowArrayRelease(batch.first.get());
+        ArrowSchemaRelease(batch.second.get());
+    }
+
+    std::shared_ptr<paimon::Metrics> metrics = reader->GetReaderMetrics();
+    PAIMON_ASSIGN_OR_RAISE(
+        stats.row_groups_total,
+        ReadCounter(metrics, 
paimon::parquet::ParquetMetrics::READ_ROW_GROUPS_TOTAL));
+    PAIMON_ASSIGN_OR_RAISE(
+        stats.row_groups_after_filter,
+        ReadCounter(metrics, 
paimon::parquet::ParquetMetrics::READ_ROW_GROUPS_AFTER_FILTER));
+    PAIMON_ASSIGN_OR_RAISE(stats.batches,
+                           ReadCounter(metrics, 
paimon::parquet::ParquetMetrics::READ_BATCH_COUNT));
+    reader->Close();
+    stats.storage_bytes = storage_read_bytes->load();
+    return stats;
+}
+
+// Row groups the written file actually ended up with. The writer decides that 
itself - by row
+// count, or by its pool crossing parquet.writer.max.memory.use - so the 
footer is the only
+// reliable source.
+//
+// The write schema is deliberately not used to read back, because for two 
cases it does not
+// describe the file. A VECTOR is stored as a LIST, and CollectLeafIndices 
branches on the file
+// type, so a FixedSizeList read type against a file LIST is rejected 
outright. A dictionary column
+// is stored as its value type; that one survives leaf collection, which 
compares nothing for
+// atomic fields, and would fail later against read_data_type_ once a batch is 
read. Create()
+// resolves the file's own schema and sets the row-group counters while doing 
so, which is all this
+// needs - no read schema, no batch read, and neither trap.
+Result<uint64_t> ReadRowGroupCount(const std::shared_ptr<FileSystem>& fs, 
const std::string& path,
+                                   const std::shared_ptr<arrow::MemoryPool>& 
pool) {
+    PAIMON_ASSIGN_OR_RAISE(FileStatus file_status, fs->GetFileStatus(path));
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input, fs->Open(path));
+    auto in_stream = std::make_shared<ArrowInputStreamAdapter>(input, 
file_status.GetLen(), pool);
+    PAIMON_ASSIGN_OR_RAISE(
+        std::unique_ptr<ParquetFileBatchReader> reader,
+        ParquetFileBatchReader::Create(std::move(in_stream), /*options=*/{}, 
kReadBatchSize,
+                                       /*file_metadata=*/nullptr, 
/*storage_read_bytes=*/nullptr,
+                                       pool, /*hints=*/std::nullopt));
+    PAIMON_ASSIGN_OR_RAISE(uint64_t row_groups,
+                           ReadCounter(reader->GetReaderMetrics(),
+                                       
paimon::parquet::ParquetMetrics::READ_ROW_GROUPS_TOTAL));
+    reader->Close();
+    return row_groups;
+}
+
+// Owns a temporary directory plus the shared FileSystem / MemoryPool, removed 
on destruction.
+class BenchmarkEnv {
+ public:
+    static Result<std::unique_ptr<BenchmarkEnv>> Create() {
+        std::unique_ptr<paimon::test::UniqueTestDirectory> dir =
+            paimon::test::UniqueTestDirectory::Create();
+        if (!dir) {
+            return Status::IOError("failed to create a temporary benchmark 
directory");
+        }
+        return std::unique_ptr<BenchmarkEnv>(new BenchmarkEnv(std::move(dir)));
+    }
+
+    const std::shared_ptr<FileSystem>& fs() const {
+        return fs_;
+    }
+
+    // The reader takes an arrow pool; the writer builder allocates its own 
from the paimon pool.
+    const std::shared_ptr<arrow::MemoryPool>& arrow_pool() const {
+        return arrow_pool_;
+    }
+
+    std::string PathOf(const std::string& file_name) const {
+        return PathUtil::JoinPath(dir_->Str(), file_name);
+    }
+
+ private:
+    explicit BenchmarkEnv(std::unique_ptr<paimon::test::UniqueTestDirectory> 
dir)
+        : dir_(std::move(dir)),
+          fs_(dir_->GetFileSystem()),
+          arrow_pool_(paimon::GetArrowPool(paimon::GetDefaultPool())) {}
+
+    std::unique_ptr<paimon::test::UniqueTestDirectory> dir_;
+    std::shared_ptr<FileSystem> fs_;
+    std::shared_ptr<arrow::MemoryPool> arrow_pool_;
+};
+
+// A file the read cases scan, built once per configuration; rebuilding it 
would dominate.
+class ReadFixture {
+ public:
+    ReadFixture(const std::string& file_name, const 
std::shared_ptr<arrow::Schema>& schema,
+                const BatchFactory& make_batch,
+                const std::map<std::string, std::string>& write_options = {}) {
+        status_ = Build(file_name, schema, make_batch, write_options);
+    }
+
+    const Status& status() const {
+        return status_;
+    }
+
+    const std::string& path() const {
+        return path_;
+    }
+
+    const std::shared_ptr<FileSystem>& fs() const {
+        return env_->fs();
+    }
+
+    const std::shared_ptr<arrow::MemoryPool>& arrow_pool() const {
+        return env_->arrow_pool();
+    }
+
+    int64_t file_bytes() const {
+        return file_bytes_;
+    }
+
+ private:
+    Status Build(const std::string& file_name, const 
std::shared_ptr<arrow::Schema>& schema,
+                 const BatchFactory& make_batch,
+                 const std::map<std::string, std::string>& write_options) {
+        PAIMON_ASSIGN_OR_RAISE(env_, BenchmarkEnv::Create());
+        path_ = env_->PathOf(file_name);
+        PAIMON_ASSIGN_OR_RAISE(std::vector<std::shared_ptr<arrow::Array>> 
batches,
+                               MakeBatches(schema, make_batch, kRowsPerBatch));
+        std::map<std::string, std::string> options = write_options;
+        options[paimon::parquet::PARQUET_PAGE_SIZE] = 
std::to_string(kPageSizeBytes);
+        options[paimon::parquet::PARQUET_WRITE_MAX_ROW_GROUP_LENGTH] =
+            std::to_string(kRowGroupLength);
+        PAIMON_ASSIGN_OR_RAISE(file_bytes_, WriteParquetFile(env_->fs(), 
path_, schema, batches,
+                                                             options, 
kDefaultCompression));
+        return Status::OK();
+    }
+
+    std::unique_ptr<BenchmarkEnv> env_;
+    std::string path_;
+    int64_t file_bytes_ = 0;
+    Status status_;
+};
+
+// Built on first use, so a case excluded by --benchmark_filter never pays to 
write its file.
+// google/benchmark may report from a different thread than it registered on, 
hence the lock.
+const ReadFixture& GetFixture(const std::string& key,
+                              const 
std::function<std::unique_ptr<ReadFixture>()>& build) {
+    static std::mutex mutex;
+    static std::map<std::string, std::unique_ptr<ReadFixture>> fixtures;
+    std::lock_guard<std::mutex> guard(mutex);
+    std::unique_ptr<ReadFixture>& fixture = fixtures[key];
+    if (!fixture) {
+        fixture = build();
+    }
+    return *fixture;
+}
+
+const ReadFixture& FlatFixture() {
+    return GetFixture("flat", [] {
+        return std::make_unique<ReadFixture>("flat.parquet", FlatSchema(), 
&MakeFlatBatch);
+    });
+}
+
+const ReadFixture& NestedFixture() {
+    return GetFixture("nested", [] {
+        return std::make_unique<ReadFixture>("nested.parquet", NestedSchema(), 
&MakeNestedBatch);
+    });
+}
+
+const ReadFixture& NullableFlatFixture(int64_t null_pct) {
+    const std::string key = "flat_nulls_" + std::to_string(null_pct);
+    return GetFixture(key, [key, null_pct] {
+        return std::make_unique<ReadFixture>(
+            key + ".parquet", FlatSchema(),
+            [null_pct](const std::shared_ptr<arrow::Schema>& schema, int64_t 
offset, int64_t rows) {
+                return MakeNullableFlatBatch(schema, offset, rows, null_pct);
+            });
+    });
+}
+
+// A one-column file, for read cases that isolate a single decoder.
+const ReadFixture& ColumnFixture(const std::string& key,
+                                 const std::shared_ptr<arrow::Schema>& schema,
+                                 const ColumnFactory& make_column) {
+    return GetFixture(key, [key, schema, make_column] {
+        return std::make_unique<ReadFixture>(key + ".parquet", schema,
+                                             SingleColumnBatch(make_column));
+    });
+}
+
+// The flat fixture only carries precision 18, so without this the 
FIXED_LEN_BYTE_ARRAY path that
+// precision 38 takes is written but never read.
+const ReadFixture& DecimalFixture(int32_t precision) {
+    return ColumnFixture("decimal_" + std::to_string(precision), 
DecimalSchema(precision),
+                         [precision](int64_t rows, int64_t offset) {
+                             return MakeDecimalColumn(rows, offset, precision, 
/*scale=*/4);
+                         });
+}
+
+// The flat schema carries no floating-point column, so a DOUBLE read needs a 
file of its own.
+const ReadFixture& DoubleFixture() {
+    return ColumnFixture("double", DoubleSchema(), &MakeDoubleColumn);
+}
+
+// The same data with dictionary encoding off, giving the read side a plain 
baseline.
+const ReadFixture& PlainFlatFixture() {
+    return GetFixture("flat_plain", [] {
+        std::map<std::string, std::string> options;
+        options[paimon::parquet::PARQUET_ENABLE_DICTIONARY] = "false";
+        return std::make_unique<ReadFixture>("flat_plain.parquet", 
FlatSchema(), &MakeFlatBatch,
+                                             options);
+    });
+}
+
+// google/benchmark's own main exits 0 whatever happened, so a case that 
called SkipWithError
+// prints as skipped while `ctest -L benchmark` still passes. Nothing here 
skips on purpose, so
+// the flag every error sets becomes the process exit code. It is recorded 
here rather than in a
+// custom reporter because passing one to RunSpecifiedBenchmarks would bypass
+// CreateDefaultDisplayReporter and with it --benchmark_format, 
--benchmark_color and
+// --benchmark_counters_tabular.
+std::atomic<bool> g_failed{false};
+
+bool FailBenchmark(::benchmark::State& state, const Status& status) {
+    if (status.ok()) {
+        return false;
+    }
+    g_failed.store(true);
+    state.SkipWithError(status.ToString().c_str());
+    return true;
+}
+
+class Timer {
+ public:
+    double ElapsedNanos() const {
+        return std::chrono::duration<double, 
std::nano>(std::chrono::steady_clock::now() - started_)
+            .count();
+    }
+
+ private:
+    std::chrono::steady_clock::time_point started_ = 
std::chrono::steady_clock::now();
+};
+
+// google/benchmark reports items/s; ns per row is what the issue asks for, so 
the timed region
+// is also measured directly and divided by the row count.
+void ReportRowRate(::benchmark::State& state, int64_t rows, double elapsed_ns) 
{
+    state.SetItemsProcessed(static_cast<int64_t>(state.iterations()) * rows);
+    const double total_rows = static_cast<double>(state.iterations()) * 
static_cast<double>(rows);
+    if (total_rows > 0) {
+        state.counters["ns_per_row"] = ::benchmark::Counter(elapsed_ns / 
total_rows);
+    }
+}
+
+// Bytes per iteration, plus the per-row form that shows a CPU-for-size trade 
next to ns_per_row.
+void ReportBytes(::benchmark::State& state, const std::string& name, int64_t 
bytes, int64_t rows) {
+    state.counters[name] = ::benchmark::Counter(static_cast<double>(bytes));
+    if (rows > 0) {
+        state.counters["bytes_per_row"] =
+            ::benchmark::Counter(static_cast<double>(bytes) / 
static_cast<double>(rows));
+    }
+}
+
+// Everything the writer needs per file - properties, output stream, schema 
conversion, footer -
+// is inside the timed region, because that is what a caller pays per data 
file.
+void RunWriteBenchmark(::benchmark::State& state, const 
std::shared_ptr<arrow::Schema>& schema,
+                       const BatchFactory& make_batch, int64_t rows_per_batch,
+                       const std::map<std::string, std::string>& options,
+                       const std::string& compression, int64_t total_rows = 
kRowsPerFile,
+                       BatchReuse reuse = BatchReuse::kFresh) {
+    Result<std::unique_ptr<BenchmarkEnv>> env = BenchmarkEnv::Create();
+    if (FailBenchmark(state, env.status())) {
+        return;
+    }
+    Result<std::vector<std::shared_ptr<arrow::Array>>> batches =
+        MakeBatches(schema, make_batch, rows_per_batch, total_rows, reuse);
+    if (FailBenchmark(state, batches.status())) {
+        return;
+    }
+    const std::string path = env.value()->PathOf("write_case.parquet");
+
+    int64_t file_bytes = 0;
+    Timer timer;
+    for (auto _ : state) {
+        Result<int64_t> written = WriteParquetFile(env.value()->fs(), path, 
schema, batches.value(),
+                                                   options, compression);
+        if (FailBenchmark(state, written.status())) {
+            return;
+        }
+        file_bytes = written.value();
+    }
+    // Captured before the read-back below, which reopens the file and parses 
its footer. Reading
+    // the timer after would put that inside ns_per_row but not inside 
google/benchmark's own
+    // real_time, leaving the two disagreeing by a fixed amount that matters 
at low iteration
+    // counts.
+    const double elapsed_ns = timer.ElapsedNanos();
+
+    // Read back rather than computed: with a byte-triggered flush the count 
is not predictable
+    // from the arguments, and where a row-count limit does make it 
predictable, reporting the real
+    // number is what would catch the prediction being wrong.
+    Result<uint64_t> row_groups =
+        ReadRowGroupCount(env.value()->fs(), path, env.value()->arrow_pool());
+    if (FailBenchmark(state, row_groups.status())) {
+        return;
+    }
+
+    ReportRowRate(state, total_rows, elapsed_ns);
+    ReportBytes(state, "file_bytes", file_bytes, total_rows);
+    state.counters["batches"] = ::benchmark::Counter(
+        static_cast<double>((total_rows + rows_per_batch - 1) / 
rows_per_batch));
+    state.counters["row_groups"] = 
::benchmark::Counter(static_cast<double>(row_groups.value()));
+}
+
+// Single-column variant: one column isolates one encoder.
+void RunColumnWriteBenchmark(::benchmark::State& state, const 
std::shared_ptr<arrow::Field>& field,
+                             const ColumnFactory& make_column, int64_t 
rows_per_batch,
+                             const std::map<std::string, std::string>& options,
+                             int64_t total_rows = kRowsPerFile,
+                             BatchReuse reuse = BatchReuse::kFresh) {
+    RunWriteBenchmark(state, arrow::schema({field}), 
SingleColumnBatch(make_column), rows_per_batch,
+                      options, kDefaultCompression, total_rows, reuse);
+}
+
+void BM_ParquetWrite_Int64(::benchmark::State& state) {
+    RunColumnWriteBenchmark(state, MakeField("id", arrow::int64(), 0), 
&MakeInt64Column,
+                            kRowsPerBatch, /*options=*/{});
+}
+
+void BM_ParquetWrite_Double(::benchmark::State& state) {
+    RunColumnWriteBenchmark(state, MakeField("value", arrow::float64(), 0), 
&MakeDoubleColumn,
+                            kRowsPerBatch, /*options=*/{});
+}
+
+void BM_ParquetWrite_Boolean(::benchmark::State& state) {
+    RunColumnWriteBenchmark(state, MakeField("flag", arrow::boolean(), 0), 
&MakeBooleanColumn,
+                            kRowsPerBatch, /*options=*/{});
+}
+
+void BM_ParquetWrite_String(::benchmark::State& state) {
+    const int64_t cardinality = state.range(0);
+    RunColumnWriteBenchmark(state, MakeField("name", arrow::utf8(), 0),
+                            [cardinality](int64_t rows, int64_t offset) {
+                                return MakeStringColumn(rows, offset, 
cardinality);
+                            },
+                            kRowsPerBatch, /*options=*/{});
+}
+
+// arg: number of distinct values. The flat control for 
BM_ParquetWrite_DictionaryInt32.
+void BM_ParquetWrite_FlatInt32(::benchmark::State& state) {
+    const int64_t cardinality = state.range(0);
+    RunColumnWriteBenchmark(state, MakeField("value", arrow::int32(), 0),
+                            [cardinality](int64_t rows, int64_t offset) {
+                                return MakeInt32Column(rows, offset, 
cardinality);
+                            },
+                            kRowsPerBatch, /*options=*/{});
+}
+
+// arg: dictionary cardinality. Same values as BM_ParquetWrite_String at the 
same cardinality, but
+// handed to the writer already dictionary-encoded - nothing in 
ParquetWriterBuilder rejects a
+// dictionary arrow type, so the writer does reach this path - and the pair 
isolates what it saves
+// when it can pass indices through instead of materializing every value.
+void BM_ParquetWrite_DictionaryString(::benchmark::State& state) {
+    const int64_t cardinality = state.range(0);
+    RunColumnWriteBenchmark(state, MakeField("name", DictionaryStringType(), 
0),
+                            [cardinality](int64_t rows, int64_t offset) {
+                                return MakeDictionaryStringColumn(rows, 
offset, cardinality);
+                            },
+                            kRowsPerBatch, /*options=*/{});
+}
+
+// The same axis on an INTEGER dictionary, which arrow cannot direct-write - 
is_base_binary_like
+// excludes int32, so it densifies first. Its baseline is 
BM_ParquetWrite_FlatInt32 at the same
+// cardinality, not the String case: only the flat INT32 control holds value, 
width and encoding
+// fixed, so only that delta is the materialization cost.
+void BM_ParquetWrite_DictionaryInt32(::benchmark::State& state) {
+    const int64_t cardinality = state.range(0);
+    RunColumnWriteBenchmark(state, MakeField("value", DictionaryInt32Type(), 
0),
+                            [cardinality](int64_t rows, int64_t offset) {
+                                return MakeDictionaryInt32Column(rows, offset, 
cardinality);
+                            },
+                            kRowsPerBatch, /*options=*/{});
+}
+
+// Same data with dictionary encoding turned off, as an encoding baseline.
+void BM_ParquetWrite_StringNoDictionary(::benchmark::State& state) {
+    const int64_t cardinality = state.range(0);
+    std::map<std::string, std::string> options;
+    options[paimon::parquet::PARQUET_ENABLE_DICTIONARY] = "false";
+    RunColumnWriteBenchmark(
+        state, MakeField("name", arrow::utf8(), 0),
+        [cardinality](int64_t rows, int64_t offset) {
+            return MakeStringColumn(rows, offset, cardinality);
+        },
+        kRowsPerBatch, options);
+}
+
+void BM_ParquetWrite_Decimal(::benchmark::State& state) {
+    const auto precision = static_cast<int32_t>(state.range(0));
+    RunColumnWriteBenchmark(state, MakeField("amount", 
arrow::decimal128(precision, 4), 0),
+                            [precision](int64_t rows, int64_t offset) {
+                                return MakeDecimalColumn(rows, offset, 
precision, /*scale=*/4);
+                            },
+                            kRowsPerBatch, /*options=*/{});
+}
+
+void BM_ParquetWrite_Struct(::benchmark::State& state) {
+    RunColumnWriteBenchmark(state, MakeField("info", StructColumnType(), 0), 
&MakeStructColumn,
+                            kRowsPerBatch, /*options=*/{});
+}
+
+void BM_ParquetWrite_List(::benchmark::State& state) {
+    RunColumnWriteBenchmark(state, MakeField("tags", ListColumnType(), 0), 
&MakeListColumn,
+                            kRowsPerBatch, /*options=*/{});
+}
+
+void BM_ParquetWrite_Vector(::benchmark::State& state) {
+    RunColumnWriteBenchmark(state, MakeField("embedding", VectorColumnType(), 
0), &MakeVectorColumn,
+                            kRowsPerBatch, /*options=*/{});
+}
+
+// arg: entries per map row.
+void BM_ParquetWrite_Map(::benchmark::State& state) {
+    const auto entries = static_cast<int32_t>(state.range(0));
+    RunColumnWriteBenchmark(
+        state, MakeField("attrs", MapColumnType(), 0),
+        [entries](int64_t rows, int64_t offset) { return MakeMapColumn(rows, 
offset, entries); },
+        kRowsPerBatch, /*options=*/{});
+}
+
+// arg: percentage of nulls. Every field is nullable, so the column is 
`optional` and definition
+// levels are written at every density including zero; the sweep moves what 
arrow's null-free fast
+// path is worth. BIGINT is the narrowest column, so levels are the largest 
share of what is left.
+void BM_ParquetWrite_Nulls(::benchmark::State& state) {
+    const int64_t null_pct = state.range(0);
+    RunColumnWriteBenchmark(state, MakeField("id", arrow::int64(), 0),
+                            NullableColumnFactory(&MakeInt64Column, null_pct), 
kRowsPerBatch,
+                            /*options=*/{});
+}
+
+// arg: rows per AddBatch call, at a fixed total row count. Smaller batches 
mean the same data
+// carries more per-batch fixed cost, and the file-level setup is amortized 
over more calls.
+void BM_ParquetWrite_BatchSize(::benchmark::State& state) {
+    RunColumnWriteBenchmark(state, MakeField("id", arrow::int64(), 0), 
&MakeInt64Column,
+                            state.range(0), /*options=*/{});
+}
+
+// arg: number of columns at a fixed row count. With BM_ParquetWrite_BatchSize 
this separates the
+// two ways per-AddBatch work grows - more calls, or more columns per call - 
where that work is
+// AddBatch importing the C array plus arrow walking the schema, neither of 
which encodes a value.
+void BM_ParquetWrite_ColumnCount(::benchmark::State& state) {
+    const auto columns = static_cast<int32_t>(state.range(0));
+    arrow::FieldVector fields;
+    fields.reserve(columns);
+    for (int32_t i = 0; i < columns; ++i) {
+        fields.push_back(MakeField("c" + std::to_string(i), arrow::utf8(), i));
+    }
+    RunWriteBenchmark(state, arrow::schema(fields), &MakeWideBatch, 
kRowsPerBatch, /*options=*/{},
+                      kDefaultCompression);
+    state.counters["columns"] = 
::benchmark::Counter(static_cast<double>(columns));
+}
+
+// arg: maximum rows per row group, the write-side half of 
BM_ParquetRead_Filtered - a row group
+// is the unit the reader prunes, so more of them buys finer pruning at the 
cost of footer
+// metadata, flush work and codec material. The byte-triggered counterpart is
+// BM_ParquetWrite_MemoryThreshold.
+void BM_ParquetWrite_RowGroupSize(::benchmark::State& state) {
+    const int64_t row_group_length = state.range(0);
+    std::map<std::string, std::string> options;
+    options[paimon::parquet::PARQUET_WRITE_MAX_ROW_GROUP_LENGTH] = 
std::to_string(row_group_length);
+    RunWriteBenchmark(state, FlatSchema(), &MakeFlatBatch, kRowsPerBatch, 
options,
+                      kDefaultCompression);
+}
+
+// args: the parquet.writer.max.memory.use threshold in KiB, and the number of 
AddBatch calls.
+// Unlike every other case here the file is not a fixed kRowsPerFile: batches 
are a fixed 10'000
+// rows and the total grows with the batch count, because the question is 
whether retained size
+// accumulates across batches until the writer cuts a row group. Holding the 
total fixed and
+// shrinking the batch would measure AddBatch granularity instead, which is 
what
+// BM_ParquetWrite_BatchSize already does. The input is low-cardinality 
dictionary data, where
+// retained and flat size differ most, written as one batch object repeatedly 
rather than a fresh
+// one each time so arrow sees the same dictionary rather than N equal ones.
+//
+// Nothing here sets a row-group row limit, so the byte threshold is the only 
thing that can
+// trigger a flush. row_groups reports whether it fired at all; if it reads 1 
everywhere the sweep
+// measured nothing, and the unit test only covers the mechanism, not this 
particular setting.
+void BM_ParquetWrite_MemoryThreshold(::benchmark::State& state) {
+    constexpr int64_t kFlushRowsPerBatch = 10'000;
+    std::map<std::string, std::string> options;
+    options[paimon::parquet::PARQUET_WRITER_MAX_MEMORY_USE] = 
std::to_string(state.range(0) * 1024);
+    RunColumnWriteBenchmark(
+        state, MakeField("name", DictionaryStringType(), 0),
+        [](int64_t rows, int64_t offset) {
+            return MakeDictionaryStringColumn(rows, offset, 
kLowStringCardinality);
+        },
+        kFlushRowsPerBatch, options, kFlushRowsPerBatch * state.range(1), 
BatchReuse::kReused);
+}
+
+// Codec sweep over the mixed flat schema, so the CPU-versus-size trade shows 
on data that is not
+// uniformly one type. Levels stay at the ParquetWriterBuilder defaults. The 
names are the ones
+// Parquet accepts, not the ones arrow does: "lz4" resolves to arrow's 
LZ4_FRAME, which
+// parquet::IsCodecSupported rejects, so the two framings Parquet defines are 
spelled out.
+void BM_ParquetWrite_Compression(::benchmark::State& state, const char* 
compression) {
+    RunWriteBenchmark(state, FlatSchema(), &MakeFlatBatch, kRowsPerBatch, 
/*options=*/{},
+                      compression);
+}
+
+// Rows a read case has to materialize for its number to mean anything. 
Pruning is not precise, so
+// a filtered case gets a range rather than an exact count. Without the bound, 
a fixture that
+// silently stopped producing rows would just look fast.
+struct RowExpectation {
+    int64_t min = kRowsPerFile;
+    int64_t max = kRowsPerFile;
+};
+
+void RunReadBenchmark(::benchmark::State& state, const ReadFixture& fixture,
+                      const std::shared_ptr<arrow::Schema>& read_schema,
+                      const std::shared_ptr<Predicate>& predicate,
+                      const std::optional<RoaringBitmap32>& selection_bitmap,
+                      const std::map<std::string, std::string>& options, 
int32_t batch_size,
+                      const RowExpectation& expected = {}) {
+    if (FailBenchmark(state, fixture.status())) {
+        return;
+    }
+
+    ReadStats stats;
+    Timer timer;
+    for (auto _ : state) {
+        Result<ReadStats> result =
+            ReadParquetFile(fixture.fs(), fixture.path(), read_schema, 
predicate, selection_bitmap,
+                            options, batch_size, fixture.arrow_pool());
+        if (FailBenchmark(state, result.status())) {
+            return;
+        }
+        stats = result.value();
+    }
+    if (stats.rows < expected.min || stats.rows > expected.max) {
+        FailBenchmark(state, Status::Invalid("read " + 
std::to_string(stats.rows) +
+                                             " rows, expected " + 
std::to_string(expected.min) +
+                                             " to " + 
std::to_string(expected.max)));
+        return;
+    }
+
+    const double elapsed_ns = timer.ElapsedNanos();
+    ReportRowRate(state, stats.rows, elapsed_ns);
+    // Normalized by what the file holds, not what this case materialized: 
pruning drops the
+    // per-materialized-row numerator and denominator together, so ns_per_row 
and bytes_per_row can
+    // rise while the run gets faster. Only a denominator every setting shares 
is comparable across
+    // settings that prune by different amounts.
+    const double total_input_rows =
+        static_cast<double>(state.iterations()) * 
static_cast<double>(kRowsPerFile);
+    if (total_input_rows > 0) {
+        state.counters["ns_per_input_row"] = ::benchmark::Counter(elapsed_ns / 
total_input_rows);
+        state.counters["bytes_per_input_row"] = ::benchmark::Counter(
+            static_cast<double>(stats.storage_bytes) / 
static_cast<double>(kRowsPerFile));
+    }
+    ReportBytes(state, "read_bytes", 
static_cast<int64_t>(stats.storage_bytes), stats.rows);
+    state.counters["rows_read"] = 
::benchmark::Counter(static_cast<double>(stats.rows));
+    state.counters["file_bytes"] = 
::benchmark::Counter(static_cast<double>(fixture.file_bytes()));
+    state.counters["row_groups"] =
+        ::benchmark::Counter(static_cast<double>(stats.row_groups_total));
+    state.counters["row_groups_after_filter"] =
+        
::benchmark::Counter(static_cast<double>(stats.row_groups_after_filter));
+    state.counters["batches"] = 
::benchmark::Counter(static_cast<double>(stats.batches));
+}
+
+void BM_ParquetRead_FullScan(::benchmark::State& state) {
+    RunReadBenchmark(state, FlatFixture(), FlatSchema(), /*predicate=*/nullptr,
+                     /*selection_bitmap=*/std::nullopt, /*options=*/{}, 
kReadBatchSize);
+}
+
+// One column at a time, separating per-column transfer from materializing the 
whole row.
+void BM_ParquetRead_Projection(::benchmark::State& state, const char* column) {
+    std::shared_ptr<arrow::Field> field = FlatSchema()->GetFieldByName(column);
+    if (!field) {
+        FailBenchmark(state, Status::Invalid("unknown projection column"));
+        return;
+    }
+    RunReadBenchmark(state, FlatFixture(), arrow::schema({field}), 
/*predicate=*/nullptr,
+                     /*selection_bitmap=*/std::nullopt, /*options=*/{}, 
kReadBatchSize);
+}
+
+// args: percentage of rows the predicate keeps, and whether page-level 
pruning is on. The `id`
+// column is ordered, so the surviving rows form a prefix; running both 
page-index settings makes
+// the pruning gain attributable instead of merely visible.
+void BM_ParquetRead_Filtered(::benchmark::State& state) {
+    const int64_t threshold = kRowsPerFile * state.range(0) / 100;
+    // `id` is ordered, so row-group statistics alone must already discard 
every group past the
+    // threshold - that holds with page-index filtering off too. Bounding at 
the row-group grain
+    // rather than at kRowsPerFile is what makes a pruning regression fail the 
case instead of
+    // quietly reading the whole file.
+    const int64_t row_group_bound =
+        ((threshold + kRowGroupLength - 1) / kRowGroupLength) * 
kRowGroupLength;
+    const bool enable_page_index = state.range(1) != 0;
+    std::shared_ptr<Predicate> predicate = PredicateBuilder::LessThan(
+        /*field_index=*/0, /*field_name=*/"id", FieldType::BIGINT, 
Literal(threshold));
+    std::map<std::string, std::string> options;
+    options[paimon::parquet::PARQUET_READ_ENABLE_PAGE_INDEX_FILTER] =
+        enable_page_index ? "true" : "false";
+    RunReadBenchmark(state, FlatFixture(), FlatSchema(), predicate,
+                     /*selection_bitmap=*/std::nullopt, options, 
kReadBatchSize,
+                     RowExpectation{threshold, row_group_bound});
+}
+
+// arg: distance between selected rows. The strides straddle the coalesce hole 
limit (32 rows by
+// default), which splits this into two regimes: at or below it neighbouring 
single-row ranges
+// merge into long spans, so rows_read runs far ahead of selected_rows and 
nothing is skipped;
+// above it every row stays its own range and arrow's Skip decodes and 
discards each gap instead.
+// Compare read_bytes and ns_per_input_row across the two.
+void BM_ParquetRead_SkipHeavy(::benchmark::State& state) {
+    const int64_t stride = state.range(0);
+    RoaringBitmap32 bitmap;
+    for (int64_t row = 0; row < kRowsPerFile; row += stride) {
+        bitmap.Add(static_cast<int32_t>(row));
+    }
+    const int64_t selected = bitmap.Cardinality();
+    state.counters["selected_rows"] = 
::benchmark::Counter(static_cast<double>(selected));
+    RunReadBenchmark(state, FlatFixture(), FlatSchema(), 
/*predicate=*/nullptr, bitmap,
+                     /*options=*/{}, kReadBatchSize, RowExpectation{selected, 
kRowsPerFile});
+}
+
+// arg: percentage of nulls, the read side of BM_ParquetWrite_Nulls: 
definition levels have to be
+// decoded back into a validity bitmap, and past some density arrow may do 
less work, not more.
+void BM_ParquetRead_Nulls(::benchmark::State& state) {
+    RunReadBenchmark(state, NullableFlatFixture(state.range(0)), FlatSchema(),
+                     /*predicate=*/nullptr, /*selection_bitmap=*/std::nullopt, 
/*options=*/{},
+                     kReadBatchSize);
+}
+
+// The VARCHAR column from a dictionary-encoded file and from a plain-encoded 
one. Both are
+// decoded by arrow, so this is arrow's dictionary path against its plain path 
on equal values.
+void BM_ParquetRead_Encoding(::benchmark::State& state, bool 
enable_dictionary) {
+    std::shared_ptr<arrow::Field> field = FlatSchema()->GetFieldByName("name");
+    RunReadBenchmark(state, enable_dictionary ? FlatFixture() : 
PlainFlatFixture(),
+                     arrow::schema({field}), /*predicate=*/nullptr,
+                     /*selection_bitmap=*/std::nullopt, /*options=*/{}, 
kReadBatchSize);
+}
+
+// arg: decimal precision, the read side of BM_ParquetWrite_Decimal. Precision 
picks the physical
+// type - INT32, INT64 or FIXED_LEN_BYTE_ARRAY, since ParquetWriterBuilder 
enables
+// store_decimal_as_integer - and the three take different paths back to 
Decimal128Array.
+void BM_ParquetRead_Decimal(::benchmark::State& state) {
+    const auto precision = static_cast<int32_t>(state.range(0));
+    RunReadBenchmark(state, DecimalFixture(precision), 
DecimalSchema(precision),
+                     /*predicate=*/nullptr, /*selection_bitmap=*/std::nullopt, 
/*options=*/{},
+                     kReadBatchSize);
+}
+
+void BM_ParquetRead_Double(::benchmark::State& state) {
+    RunReadBenchmark(state, DoubleFixture(), DoubleSchema(), 
/*predicate=*/nullptr,
+                     /*selection_bitmap=*/std::nullopt, /*options=*/{}, 
kReadBatchSize);
+}
+
+// arg: rows per NextBatch call. At a fixed row count this turns the per-batch 
fixed cost of
+// ParquetFileBatchReader::NextBatch - Validate, the two metrics counters, the 
ArrowSchema
+// export - into a number, separated from the per-row decoding cost.
+void BM_ParquetRead_BatchSize(::benchmark::State& state) {
+    RunReadBenchmark(state, FlatFixture(), FlatSchema(), /*predicate=*/nullptr,
+                     /*selection_bitmap=*/std::nullopt, /*options=*/{},
+                     static_cast<int32_t>(state.range(0)));
+}
+
+// Each nested column costs definition and repetition levels a flat column 
does not pay.
+void BM_ParquetRead_Nested(::benchmark::State& state, const char* column) {
+    std::shared_ptr<arrow::Schema> full_schema = NestedReadSchema();
+    std::shared_ptr<arrow::Schema> read_schema = full_schema;
+    if (column != nullptr) {
+        std::shared_ptr<arrow::Field> field = 
full_schema->GetFieldByName(column);
+        if (!field) {
+            FailBenchmark(state, Status::Invalid("unknown nested column"));
+            return;
+        }
+        read_schema = arrow::schema({field});
+    }
+    RunReadBenchmark(state, NestedFixture(), read_schema, 
/*predicate=*/nullptr,
+                     /*selection_bitmap=*/std::nullopt, /*options=*/{}, 
kReadBatchSize);
+}
+
+}  // namespace
+
+BENCHMARK(BM_ParquetWrite_Int64)->Unit(benchmark::kMillisecond)->UseRealTime();
+BENCHMARK(BM_ParquetWrite_Double)->Unit(benchmark::kMillisecond)->UseRealTime();
+BENCHMARK(BM_ParquetWrite_Boolean)->Unit(benchmark::kMillisecond)->UseRealTime();
+BENCHMARK(BM_ParquetWrite_String)
+    ->ArgName("cardinality")
+    ->Arg(10)
+    ->Arg(1000)
+    ->Arg(kRowsPerFile)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_StringNoDictionary)
+    ->ArgName("cardinality")
+    ->Arg(10)
+    ->Arg(1000)
+    ->Arg(kRowsPerFile)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_FlatInt32)
+    ->ArgName("cardinality")
+    ->Arg(10)
+    ->Arg(100)
+    ->Arg(1000)
+    ->Arg(10000)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_DictionaryString)
+    ->ArgName("cardinality")
+    ->Arg(10)
+    ->Arg(100)
+    ->Arg(1000)
+    ->Arg(10000)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_DictionaryInt32)
+    ->ArgName("cardinality")
+    ->Arg(10)
+    ->Arg(100)
+    ->Arg(1000)
+    ->Arg(10000)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_Decimal)
+    ->ArgName("precision")
+    ->Arg(9)
+    ->Arg(18)
+    ->Arg(38)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_Struct)->Unit(benchmark::kMillisecond)->UseRealTime();
+BENCHMARK(BM_ParquetWrite_List)->Unit(benchmark::kMillisecond)->UseRealTime();
+BENCHMARK(BM_ParquetWrite_Vector)->Unit(benchmark::kMillisecond)->UseRealTime();
+BENCHMARK(BM_ParquetWrite_Map)
+    ->ArgName("entries")
+    ->Arg(3)
+    ->Arg(5)
+    ->Arg(10)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_Nulls)
+    ->ArgName("null_pct")
+    ->Arg(0)
+    ->Arg(20)
+    ->Arg(50)
+    ->Arg(70)
+    ->Arg(100)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_BatchSize)
+    ->ArgName("rows_per_batch")
+    ->Arg(100)
+    ->Arg(1000)
+    ->Arg(10000)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_ColumnCount)
+    ->ArgName("columns")
+    ->Arg(1)
+    ->Arg(5)
+    ->Arg(10)
+    ->Arg(20)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_RowGroupSize)
+    ->ArgName("row_group_rows")
+    ->Arg(5000)
+    ->Arg(25000)
+    ->Arg(kRowsPerFile)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetWrite_MemoryThreshold)
+    ->ArgNames({"max_memory_kib", "batches"})
+    ->Args({512, 50})
+    ->Args({512, 200})
+    ->Args({4096, 200})
+    ->Args({65536, 200})
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetWrite_Compression, none, "none")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetWrite_Compression, snappy, "snappy")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetWrite_Compression, gzip, "gzip")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetWrite_Compression, lz4_raw, "lz4_raw")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetWrite_Compression, lz4_hadoop, "lz4_hadoop")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetWrite_Compression, brotli, "brotli")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetWrite_Compression, zstd, "zstd")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+
+BENCHMARK(BM_ParquetRead_FullScan)->Unit(benchmark::kMillisecond)->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetRead_Projection, id, "id")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetRead_Projection, name, "name")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetRead_Projection, amount, "amount")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetRead_Filtered)
+    ->ArgNames({"keep_pct", "page_index"})
+    ->Args({1, 1})
+    ->Args({1, 0})
+    ->Args({10, 1})
+    ->Args({10, 0})
+    ->Args({50, 1})
+    ->Args({50, 0})
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetRead_SkipHeavy)
+    ->ArgName("stride")
+    ->Arg(8)
+    ->Arg(32)
+    ->Arg(64)
+    ->Arg(512)
+    ->Arg(4096)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetRead_Nulls)
+    ->ArgName("null_pct")
+    ->Arg(0)
+    ->Arg(20)
+    ->Arg(50)
+    ->Arg(70)
+    ->Arg(100)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetRead_Encoding, dictionary, true)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetRead_Encoding, plain, false)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetRead_Decimal)
+    ->ArgName("precision")
+    ->Arg(9)
+    ->Arg(18)
+    ->Arg(38)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK(BM_ParquetRead_Double)->Unit(benchmark::kMillisecond)->UseRealTime();
+BENCHMARK(BM_ParquetRead_BatchSize)
+    ->ArgName("batch_size")
+    ->Arg(512)
+    ->Arg(4096)
+    ->Arg(16384)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetRead_Nested, info_struct, "info")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetRead_Nested, tags_list, "tags")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetRead_Nested, embedding_vector, "embedding")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetRead_Nested, attrs_map, "attrs")
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+BENCHMARK_CAPTURE(BM_ParquetRead_Nested, all_columns, nullptr)
+    ->Unit(benchmark::kMillisecond)
+    ->UseRealTime();
+
+int main(int argc, char** argv) {
+    ::benchmark::Initialize(&argc, argv);
+    if (::benchmark::ReportUnrecognizedArguments(argc, argv)) {
+        return 1;
+    }
+    ::benchmark::RunSpecifiedBenchmarks();
+    ::benchmark::Shutdown();
+    return g_failed.load() ? 1 : 0;
+}
diff --git a/benchmark/parquet_format_benchmark_test.cpp 
b/benchmark/parquet_format_benchmark_test.cpp
new file mode 100644
index 00000000..e930449d
--- /dev/null
+++ b/benchmark/parquet_format_benchmark_test.cpp
@@ -0,0 +1,533 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Smoke test for the assumptions parquet_format_benchmark.cpp is built on.
+//
+// The benchmark is only compiled under PAIMON_BUILD_BENCHMARKS, which CI does 
not set, so nothing
+// there runs in CI. Every assumption it makes about the format layer - that a 
codec name is
+// accepted, that a dictionary-encoded input array can be written, that a 
nested or high-precision
+// column survives a round trip, that a predicate and a selection bitmap 
return the rows the
+// benchmark asserts on, that the reader metrics it reports exist - is checked 
here instead, at a
+// row count small enough to stay a test. A regression in any of them would 
otherwise surface as a
+// benchmark that quietly measures the wrong thing.
+
+#include <cstdint>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/api.h"
+#include "arrow/array/concatenate.h"
+#include "arrow/c/abi.h"
+#include "arrow/c/bridge.h"
+#include "arrow/c/helpers.h"
+#include "gtest/gtest.h"
+#include "paimon/common/utils/arrow/arrow_input_stream_adapter.h"
+#include "paimon/common/utils/arrow/mem_utils.h"
+#include "paimon/common/utils/checked_cast.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/defs.h"
+#include "paimon/format/format_writer.h"
+#include "paimon/format/parquet/parquet_field_id_converter.h"
+#include "paimon/format/parquet/parquet_file_batch_reader.h"
+#include "paimon/format/parquet/parquet_format_defs.h"
+#include "paimon/format/parquet/parquet_writer_builder.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/metrics.h"
+#include "paimon/predicate/literal.h"
+#include "paimon/predicate/predicate.h"
+#include "paimon/predicate/predicate_builder.h"
+#include "paimon/reader/batch_reader.h"
+#include "paimon/result.h"
+#include "paimon/status.h"
+#include "paimon/testing/utils/testharness.h"
+#include "paimon/utils/roaring_bitmap32.h"
+
+namespace paimon::parquet {
+namespace {
+
+// Small enough to stay a test, large enough to span several batches and row 
groups.
+constexpr int64_t kRows = 2'000;
+constexpr int32_t kBatchSize = 256;
+constexpr int64_t kRowGroupLength = 500;
+
+std::shared_ptr<arrow::Field> MakeField(const std::string& name,
+                                        const 
std::shared_ptr<arrow::DataType>& type,
+                                        int32_t field_id) {
+    return arrow::field(name, type,
+                        
arrow::KeyValueMetadata::Make({ParquetFieldIdConverter::PARQUET_FIELD_ID},
+                                                      
{std::to_string(field_id)}));
+}
+
+class ParquetFormatBenchmarkTest : public ::testing::Test {
+ protected:
+    void SetUp() override {
+        dir_ = test::UniqueTestDirectory::Create();
+        ASSERT_TRUE(dir_);
+        fs_ = dir_->GetFileSystem();
+        pool_ = GetArrowPool(GetDefaultPool());
+    }
+
+    std::string PathOf(const std::string& name) const {
+        return PathUtil::JoinPath(dir_->Str(), name);
+    }
+
+    // Writes the same struct array `batch_count` times through the builder 
the benchmark uses.
+    // More than one call matters for dictionary input: the benchmark always 
writes several
+    // batches, and every batch after the first hands the writer the same 
dictionary again.
+    Status Write(const std::string& path, const 
std::shared_ptr<arrow::Schema>& schema,
+                 const std::shared_ptr<arrow::Array>& batch, const 
std::string& compression,
+                 const std::map<std::string, std::string>& extra_options = {},
+                 int32_t batch_count = 1) {
+        std::map<std::string, std::string> options = extra_options;
+        // emplace, not assignment: a caller that set its own row-group limit 
is testing that.
+        options.emplace(PARQUET_WRITE_MAX_ROW_GROUP_LENGTH, 
std::to_string(kRowGroupLength));
+        ParquetWriterBuilder writer_builder(schema, kBatchSize, options);
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<OutputStream> out,
+                               fs_->Create(path, /*overwrite=*/true));
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<FormatWriter> writer,
+                               writer_builder.Build(out, compression));
+        for (int32_t i = 0; i < batch_count; ++i) {
+            ArrowArray c_array;
+            PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*batch, 
&c_array));
+            PAIMON_RETURN_NOT_OK(writer->AddBatch(&c_array));
+        }
+        PAIMON_RETURN_NOT_OK(writer->Finish());
+        return out->Close();
+    }
+
+    // Concatenating `array` with itself `times` times, so a multi-batch write 
has an expected
+    // value to be compared against.
+    static Result<std::shared_ptr<arrow::Array>> Repeat(const 
std::shared_ptr<arrow::Array>& array,
+                                                        int32_t times) {
+        std::vector<std::shared_ptr<arrow::Array>> chunks(times, array);
+        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> 
repeated,
+                                          arrow::Concatenate(chunks));
+        return repeated;
+    }
+
+    static Result<std::shared_ptr<arrow::Array>> MakeDictionary(
+        const std::shared_ptr<arrow::Array>& indices, const 
std::shared_ptr<arrow::Array>& values) {
+        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> array,
+                                          
arrow::DictionaryArray::FromArrays(indices, values));
+        return array;
+    }
+
+    struct ReadResult {
+        int64_t rows = 0;
+        uint64_t row_groups_total = 0;
+        uint64_t row_groups_after_filter = 0;
+        uint64_t batches = 0;
+        // Every batch, imported and concatenated. Row counts alone would let 
a decoding bug
+        // through, so the tests compare this against what was written.
+        std::shared_ptr<arrow::Array> data;
+    };
+
+    // Reads the file back the way the benchmark does, reporting what the 
benchmark reports on.
+    Result<ReadResult> Read(const std::string& path,
+                            const std::shared_ptr<arrow::Schema>& read_schema,
+                            const std::shared_ptr<Predicate>& predicate = 
nullptr,
+                            const std::optional<RoaringBitmap32>& selection = 
std::nullopt) {
+        PAIMON_ASSIGN_OR_RAISE(FileStatus file_status, 
fs_->GetFileStatus(path));
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input, 
fs_->Open(path));
+        auto in_stream =
+            std::make_shared<ArrowInputStreamAdapter>(input, 
file_status.GetLen(), pool_);
+        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ParquetFileBatchReader> reader,
+                               ParquetFileBatchReader::Create(
+                                   std::move(in_stream), /*options=*/{}, 
kBatchSize,
+                                   /*file_metadata=*/nullptr, 
/*storage_read_bytes=*/nullptr, pool_,
+                                   /*hints=*/std::nullopt));
+        ArrowSchema c_schema;
+        PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(*read_schema, 
&c_schema));
+        PAIMON_RETURN_NOT_OK(reader->SetReadSchema(&c_schema, predicate, 
selection));
+
+        ReadResult result;
+        std::vector<std::shared_ptr<arrow::Array>> chunks;
+        while (true) {
+            PAIMON_ASSIGN_OR_RAISE(BatchReader::ReadBatch batch, 
reader->NextBatch());
+            if (BatchReader::IsEofBatch(batch)) {
+                break;
+            }
+            // ImportArray takes ownership of both C structs, so nothing is 
released by hand here.
+            PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
+                std::shared_ptr<arrow::Array> chunk,
+                arrow::ImportArray(batch.first.get(), batch.second.get()));
+            result.rows += chunk->length();
+            chunks.push_back(std::move(chunk));
+        }
+        if (!chunks.empty()) {
+            PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(result.data, 
arrow::Concatenate(chunks));
+        }
+
+        std::shared_ptr<Metrics> metrics = reader->GetReaderMetrics();
+        PAIMON_ASSIGN_OR_RAISE(result.row_groups_total,
+                               
metrics->GetCounter(ParquetMetrics::READ_ROW_GROUPS_TOTAL));
+        PAIMON_ASSIGN_OR_RAISE(result.row_groups_after_filter,
+                               
metrics->GetCounter(ParquetMetrics::READ_ROW_GROUPS_AFTER_FILTER));
+        PAIMON_ASSIGN_OR_RAISE(result.batches,
+                               
metrics->GetCounter(ParquetMetrics::READ_BATCH_COUNT));
+        reader->Close();
+        return result;
+    }
+
+    static Result<std::shared_ptr<arrow::Array>> Wrap(
+        const std::shared_ptr<arrow::Schema>& schema,
+        const std::vector<std::shared_ptr<arrow::Array>>& columns) {
+        PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::StructArray> 
array,
+                                          arrow::StructArray::Make(columns, 
schema->fields()));
+        return checked_pointer_cast<arrow::Array>(array);
+    }
+
+    std::unique_ptr<test::UniqueTestDirectory> dir_;
+    std::shared_ptr<FileSystem> fs_;
+    std::shared_ptr<arrow::MemoryPool> pool_;
+};
+
+// Every codec name the benchmark registers has to be one Parquet accepts. 
"lz4" is the trap this
+// guards: it resolves to arrow's LZ4_FRAME, which parquet::IsCodecSupported 
rejects, and the
+// failure only shows up once a column chunk is actually written.
+TEST_F(ParquetFormatBenchmarkTest, RegisteredCodecsWrite) {
+    std::shared_ptr<arrow::Schema> schema = arrow::schema({MakeField("id", 
arrow::int64(), 0)});
+    arrow::Int64Builder builder;
+    ASSERT_TRUE(builder.Reserve(kRows).ok());
+    for (int64_t i = 0; i < kRows; ++i) {
+        builder.UnsafeAppend(i);
+    }
+    std::shared_ptr<arrow::Array> ids;
+    ASSERT_TRUE(builder.Finish(&ids).ok());
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> batch, Wrap(schema, 
{ids}));
+
+    for (const std::string& codec :
+         {"none", "snappy", "gzip", "brotli", "zstd", "lz4_raw", 
"lz4_hadoop"}) {
+        const std::string path = PathOf("codec_" + codec + ".parquet");
+        ASSERT_OK(Write(path, schema, batch, codec)) << "codec " << codec;
+        ASSERT_OK_AND_ASSIGN(ReadResult result, Read(path, schema));
+        EXPECT_EQ(kRows, result.rows) << "codec " << codec;
+        ASSERT_TRUE(result.data);
+        EXPECT_TRUE(result.data->Equals(*batch)) << "codec " << codec;
+    }
+
+    // The name the benchmark deliberately does not register still has to be 
rejected; if arrow
+    // ever starts accepting it, the comment explaining its absence is stale.
+    EXPECT_FALSE(Write(PathOf("codec_lz4.parquet"), schema, batch, 
"lz4").ok());
+}
+
+// A dictionary-encoded input array must reach the writer intact. VARCHAR 
takes arrow's direct
+// write path and INT32 gets densified first; both have to produce a readable 
file whose logical
+// values match the flat equivalent.
+TEST_F(ParquetFormatBenchmarkTest, DictionaryInputRoundTrip) {
+    constexpr int64_t kCardinality = 8;
+    arrow::Int32Builder index_builder;
+    ASSERT_TRUE(index_builder.Reserve(kRows).ok());
+    for (int64_t i = 0; i < kRows; ++i) {
+        index_builder.UnsafeAppend(static_cast<int32_t>(i % kCardinality));
+    }
+    std::shared_ptr<arrow::Array> indices;
+    ASSERT_TRUE(index_builder.Finish(&indices).ok());
+
+    arrow::StringBuilder string_values;
+    arrow::Int32Builder int_values;
+    for (int64_t i = 0; i < kCardinality; ++i) {
+        ASSERT_TRUE(string_values.Append("value_" + std::to_string(i)).ok());
+        ASSERT_TRUE(int_values.Append(static_cast<int32_t>(i * 7)).ok());
+    }
+    std::shared_ptr<arrow::Array> string_dict;
+    std::shared_ptr<arrow::Array> int_dict;
+    ASSERT_TRUE(string_values.Finish(&string_dict).ok());
+    ASSERT_TRUE(int_values.Finish(&int_dict).ok());
+
+    // The flat arrays the dictionary-encoded input has to decode back to.
+    arrow::StringBuilder flat_strings;
+    arrow::Int32Builder flat_ints;
+    for (int64_t i = 0; i < kRows; ++i) {
+        ASSERT_TRUE(flat_strings.Append("value_" + std::to_string(i % 
kCardinality)).ok());
+        ASSERT_TRUE(flat_ints.Append(static_cast<int32_t>((i % kCardinality) * 
7)).ok());
+    }
+    std::shared_ptr<arrow::Array> flat_string_column;
+    std::shared_ptr<arrow::Array> flat_int_column;
+    ASSERT_TRUE(flat_strings.Finish(&flat_string_column).ok());
+    ASSERT_TRUE(flat_ints.Finish(&flat_int_column).ok());
+
+    struct Case {
+        const char* name;
+        std::shared_ptr<arrow::Array> dictionary;
+        std::shared_ptr<arrow::DataType> read_type;
+        std::shared_ptr<arrow::Array> flat;
+    };
+    // The benchmark always writes several batches, so the writer sees the 
same dictionary more
+    // than once.
+    constexpr int32_t kBatches = 3;
+    for (const Case& c : {Case{"string", string_dict, arrow::utf8(), 
flat_string_column},
+                          Case{"int32", int_dict, arrow::int32(), 
flat_int_column}}) {
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> dictionary_array,
+                             MakeDictionary(indices, c.dictionary));
+        std::shared_ptr<arrow::Schema> write_schema =
+            arrow::schema({MakeField("v", dictionary_array->type(), 0)});
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> batch,
+                             Wrap(write_schema, {dictionary_array}));
+        const std::string path = PathOf(std::string("dict_") + c.name + 
".parquet");
+        ASSERT_OK(Write(path, write_schema, batch, "zstd", 
/*extra_options=*/{}, kBatches))
+            << c.name;
+
+        // Parquet has no dictionary type: the column comes back as its value 
type either way, and
+        // has to carry the values the flat equivalent would have.
+        std::shared_ptr<arrow::Schema> read_schema =
+            arrow::schema({MakeField("v", c.read_type, 0)});
+        ASSERT_OK_AND_ASSIGN(ReadResult result, Read(path, read_schema));
+        EXPECT_EQ(kRows * kBatches, result.rows) << c.name;
+        ASSERT_TRUE(result.data);
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> expected, 
Repeat(c.flat, kBatches));
+        std::shared_ptr<arrow::Array> actual =
+            checked_pointer_cast<arrow::StructArray>(result.data)->field(0);
+        EXPECT_TRUE(actual->Equals(*expected)) << c.name;
+    }
+}
+
+// DECIMAL precision selects the Parquet physical type, and precision 38 is 
the only one that
+// reaches FIXED_LEN_BYTE_ARRAY. The benchmark sweeps all three on both sides, 
so all three have
+// to survive a round trip with their type intact.
+TEST_F(ParquetFormatBenchmarkTest, DecimalPrecisionRoundTrip) {
+    for (int32_t precision : {9, 18, 38}) {
+        std::shared_ptr<arrow::DataType> type = arrow::decimal128(precision, 
4);
+        std::shared_ptr<arrow::Schema> schema = 
arrow::schema({MakeField("amount", type, 0)});
+        arrow::Decimal128Builder builder(type);
+        ASSERT_TRUE(builder.Reserve(kRows).ok());
+        for (int64_t i = 0; i < kRows; ++i) {
+            ASSERT_TRUE(builder.Append(arrow::Decimal128(i)).ok());
+        }
+        std::shared_ptr<arrow::Array> amounts;
+        ASSERT_TRUE(builder.Finish(&amounts).ok());
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> batch, Wrap(schema, 
{amounts}));
+
+        const std::string path = PathOf("decimal_" + std::to_string(precision) 
+ ".parquet");
+        ASSERT_OK(Write(path, schema, batch, "zstd")) << "precision " << 
precision;
+        ASSERT_OK_AND_ASSIGN(ReadResult result, Read(path, schema));
+        EXPECT_EQ(kRows, result.rows) << "precision " << precision;
+        ASSERT_TRUE(result.data);
+        EXPECT_TRUE(result.data->type()->field(0)->type()->Equals(*type))
+            << "precision " << precision;
+        EXPECT_TRUE(result.data->Equals(*batch)) << "precision " << precision;
+    }
+}
+
+// The nested fixture the benchmark reads is only meaningful if LIST and MAP 
survive the round
+// trip with the shape the read schema asks for.
+TEST_F(ParquetFormatBenchmarkTest, NestedRoundTrip) {
+    constexpr int32_t kEntries = 4;
+    auto list_values = std::make_shared<arrow::Int64Builder>();
+    arrow::ListBuilder list_builder(arrow::default_memory_pool(), list_values,
+                                    arrow::list(arrow::int64()));
+    auto key_builder = std::make_shared<arrow::StringBuilder>();
+    auto item_builder = std::make_shared<arrow::Int64Builder>();
+    arrow::MapBuilder map_builder(arrow::default_memory_pool(), key_builder, 
item_builder,
+                                  arrow::map(arrow::utf8(), arrow::int64()));
+    for (int64_t i = 0; i < kRows; ++i) {
+        ASSERT_TRUE(list_builder.Append().ok());
+        ASSERT_TRUE(map_builder.Append().ok());
+        for (int32_t j = 0; j < kEntries; ++j) {
+            ASSERT_TRUE(list_values->Append(i + j).ok());
+            ASSERT_TRUE(key_builder->Append("key_" + std::to_string(j)).ok());
+            ASSERT_TRUE(item_builder->Append(i + j).ok());
+        }
+    }
+    std::shared_ptr<arrow::Array> tags;
+    std::shared_ptr<arrow::Array> attrs;
+    ASSERT_TRUE(list_builder.Finish(&tags).ok());
+    ASSERT_TRUE(map_builder.Finish(&attrs).ok());
+
+    std::shared_ptr<arrow::Schema> schema =
+        arrow::schema({MakeField("tags", arrow::list(arrow::int64()), 0),
+                       MakeField("attrs", arrow::map(arrow::utf8(), 
arrow::int64()), 1)});
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> batch, Wrap(schema, 
{tags, attrs}));
+    const std::string path = PathOf("nested.parquet");
+    ASSERT_OK(Write(path, schema, batch, "zstd"));
+
+    ASSERT_OK_AND_ASSIGN(ReadResult result, Read(path, schema));
+    EXPECT_EQ(kRows, result.rows);
+    ASSERT_TRUE(result.data);
+    EXPECT_TRUE(result.data->Equals(*batch));
+
+    // Each nested column also has to be readable on its own, which is what 
the projected nested
+    // cases do.
+    for (const std::string& column : {"tags", "attrs"}) {
+        std::shared_ptr<arrow::Schema> projected = 
arrow::schema({schema->GetFieldByName(column)});
+        ASSERT_OK_AND_ASSIGN(ReadResult projected_result, Read(path, 
projected));
+        EXPECT_EQ(kRows, projected_result.rows) << "column " << column;
+    }
+}
+
+// The filtered and skip-heavy cases assert on row counts, so those counts 
have to mean what the
+// benchmark assumes: a predicate keeps at least the matching rows and no more 
than the row groups
+// that could hold them, and a selection bitmap keeps at least the rows it 
selected.
+TEST_F(ParquetFormatBenchmarkTest, FilteredAndBitmapRowCounts) {
+    std::shared_ptr<arrow::Schema> schema = arrow::schema({MakeField("id", 
arrow::int64(), 0)});
+    arrow::Int64Builder builder;
+    ASSERT_TRUE(builder.Reserve(kRows).ok());
+    for (int64_t i = 0; i < kRows; ++i) {
+        builder.UnsafeAppend(i);
+    }
+    std::shared_ptr<arrow::Array> ids;
+    ASSERT_TRUE(builder.Finish(&ids).ok());
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> batch, Wrap(schema, 
{ids}));
+    const std::string path = PathOf("filtered.parquet");
+    ASSERT_OK(Write(path, schema, batch, "zstd"));
+
+    ASSERT_OK_AND_ASSIGN(ReadResult full, Read(path, schema));
+    EXPECT_EQ(kRows, full.rows);
+    EXPECT_EQ(static_cast<uint64_t>(kRows / kRowGroupLength), 
full.row_groups_total);
+    EXPECT_EQ(full.row_groups_total, full.row_groups_after_filter);
+    EXPECT_GT(full.batches, 0u);
+
+    // `id` is ordered, so row-group statistics alone must discard everything 
past the threshold.
+    // This is the bound BM_ParquetRead_Filtered asserts on.
+    constexpr int64_t kThreshold = 300;
+    std::shared_ptr<Predicate> predicate = PredicateBuilder::LessThan(
+        /*field_index=*/0, /*field_name=*/"id", FieldType::BIGINT, 
Literal(kThreshold));
+    ASSERT_OK_AND_ASSIGN(ReadResult filtered, Read(path, schema, predicate));
+    EXPECT_GE(filtered.rows, kThreshold);
+    EXPECT_LE(filtered.rows, kRowGroupLength);
+    EXPECT_LT(filtered.row_groups_after_filter, filtered.row_groups_total);
+
+    // Selection is not precise, so the bitmap is a lower bound on what comes 
back.
+    RoaringBitmap32 bitmap;
+    int64_t selected = 0;
+    for (int64_t row = 0; row < kRows; row += 64) {
+        bitmap.Add(static_cast<int32_t>(row));
+        ++selected;
+    }
+    ASSERT_OK_AND_ASSIGN(ReadResult skipped, Read(path, schema, 
/*predicate=*/nullptr, bitmap));
+    EXPECT_GE(skipped.rows, selected);
+    EXPECT_LE(skipped.rows, kRows);
+}
+
+// The encoding case compares a dictionary-encoded file against a plain one, 
which is only a
+// comparison if both files read back identically.
+TEST_F(ParquetFormatBenchmarkTest, PlainAndDictionaryFilesAgree) {
+    std::shared_ptr<arrow::Schema> schema = arrow::schema({MakeField("name", 
arrow::utf8(), 0)});
+    arrow::StringBuilder builder;
+    ASSERT_TRUE(builder.Reserve(kRows).ok());
+    for (int64_t i = 0; i < kRows; ++i) {
+        ASSERT_TRUE(builder.Append("value_" + std::to_string(i % 16)).ok());
+    }
+    std::shared_ptr<arrow::Array> names;
+    ASSERT_TRUE(builder.Finish(&names).ok());
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> batch, Wrap(schema, 
{names}));
+
+    const std::string dict_path = PathOf("encoding_dict.parquet");
+    const std::string plain_path = PathOf("encoding_plain.parquet");
+    ASSERT_OK(Write(dict_path, schema, batch, "zstd"));
+    std::map<std::string, std::string> plain_options;
+    plain_options[PARQUET_ENABLE_DICTIONARY] = "false";
+    ASSERT_OK(Write(plain_path, schema, batch, "zstd", plain_options));
+
+    ASSERT_OK_AND_ASSIGN(ReadResult dict_result, Read(dict_path, schema));
+    ASSERT_OK_AND_ASSIGN(ReadResult plain_result, Read(plain_path, schema));
+    EXPECT_EQ(kRows, dict_result.rows);
+    EXPECT_EQ(dict_result.rows, plain_result.rows);
+    EXPECT_EQ(dict_result.row_groups_total, plain_result.row_groups_total);
+    ASSERT_TRUE(dict_result.data);
+    ASSERT_TRUE(plain_result.data);
+    EXPECT_TRUE(dict_result.data->Equals(*batch));
+    EXPECT_TRUE(plain_result.data->Equals(*batch));
+}
+
+// BM_ParquetWrite_MemoryThreshold rests on one assumption: that a small
+// parquet.writer.max.memory.use actually makes ParquetFormatWriter cut extra 
row groups. This
+// covers the mechanism on both input shapes that case can present it with, 
plain and
+// dictionary-encoded, with the row-count limit raised out of the way so the 
byte threshold is the
+// only thing that can flush.
+//
+// It does not cover the benchmark's own 512 KiB setting: reaching that with 
cardinality-10
+// dictionary data takes the 500K to 2M rows the benchmark writes, which is 
not a unit test. What
+// confirms that setting is the benchmark's own row_groups counter reading 
more than 1.
+TEST_F(ParquetFormatBenchmarkTest, MemoryThresholdFlushesRowGroups) {
+    constexpr int32_t kBatches = 8;
+    constexpr int64_t kDictCardinality = 10;
+
+    arrow::StringBuilder plain_builder;
+    ASSERT_TRUE(plain_builder.Reserve(kRows).ok());
+    for (int64_t i = 0; i < kRows; ++i) {
+        ASSERT_TRUE(plain_builder.Append("value_" + std::to_string(i)).ok());
+    }
+    std::shared_ptr<arrow::Array> plain_column;
+    ASSERT_TRUE(plain_builder.Finish(&plain_column).ok());
+
+    // The shape BM_ParquetWrite_MemoryThreshold writes: low-cardinality 
dictionary input.
+    arrow::StringBuilder dict_values;
+    for (int64_t i = 0; i < kDictCardinality; ++i) {
+        ASSERT_TRUE(dict_values.Append("value_" + std::to_string(i)).ok());
+    }
+    std::shared_ptr<arrow::Array> dict_value_column;
+    ASSERT_TRUE(dict_values.Finish(&dict_value_column).ok());
+    arrow::Int32Builder dict_indices;
+    ASSERT_TRUE(dict_indices.Reserve(kRows).ok());
+    for (int64_t i = 0; i < kRows; ++i) {
+        dict_indices.UnsafeAppend(static_cast<int32_t>(i % kDictCardinality));
+    }
+    std::shared_ptr<arrow::Array> index_column;
+    ASSERT_TRUE(dict_indices.Finish(&index_column).ok());
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> dict_column,
+                         MakeDictionary(index_column, dict_value_column));
+
+    struct Case {
+        const char* name;
+        std::shared_ptr<arrow::Array> column;
+    };
+    for (const Case& c : {Case{"plain", plain_column}, Case{"dictionary", 
dict_column}}) {
+        std::shared_ptr<arrow::Schema> write_schema =
+            arrow::schema({MakeField("name", c.column->type(), 0)});
+        // Parquet stores a dictionary column as its value type, so both read 
back as UTF8.
+        std::shared_ptr<arrow::Schema> read_schema =
+            arrow::schema({MakeField("name", arrow::utf8(), 0)});
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> batch, 
Wrap(write_schema, {c.column}));
+
+        std::map<std::string, std::string> options;
+        options[PARQUET_WRITE_MAX_ROW_GROUP_LENGTH] = std::to_string(kRows * 
kBatches * 10);
+
+        options[PARQUET_WRITER_MAX_MEMORY_USE] = std::to_string(uint64_t{8} * 
1024);
+        const std::string small_path =
+            PathOf(std::string("threshold_small_") + c.name + ".parquet");
+        ASSERT_OK(Write(small_path, write_schema, batch, "zstd", options, 
kBatches)) << c.name;
+        ASSERT_OK_AND_ASSIGN(ReadResult small, Read(small_path, read_schema));
+        EXPECT_EQ(kRows * kBatches, small.rows) << c.name;
+        EXPECT_GT(small.row_groups_total, 1u)
+            << c.name << ": byte threshold never triggered a flush";
+
+        options[PARQUET_WRITER_MAX_MEMORY_USE] =
+            std::to_string(DEFAULT_PARQUET_WRITER_MAX_MEMORY_USE);
+        const std::string large_path =
+            PathOf(std::string("threshold_large_") + c.name + ".parquet");
+        ASSERT_OK(Write(large_path, write_schema, batch, "zstd", options, 
kBatches)) << c.name;
+        ASSERT_OK_AND_ASSIGN(ReadResult large, Read(large_path, read_schema));
+        EXPECT_EQ(kRows * kBatches, large.rows) << c.name;
+        EXPECT_EQ(1u, large.row_groups_total) << c.name;
+
+        // Row-group boundaries must not change the data.
+        ASSERT_TRUE(small.data) << c.name;
+        ASSERT_TRUE(large.data) << c.name;
+        EXPECT_TRUE(small.data->Equals(*large.data)) << c.name;
+    }
+}
+
+}  // namespace
+}  // namespace paimon::parquet
diff --git a/docs/source/examples/benchmark.rst 
b/docs/source/examples/benchmark.rst
index d2ff1cde..b82114ee 100644
--- a/docs/source/examples/benchmark.rst
+++ b/docs/source/examples/benchmark.rst
@@ -19,8 +19,17 @@
 Benchmark Usage
 ================
 
-Paimon C++ provides Google Benchmark based cases for append-table write/read 
and
-primary-key table write/MOR read paths. Benchmarks are disabled by default.
+Paimon C++ provides Google Benchmark based cases at two levels:
+
+``paimon-read-write-benchmark``
+   Table-level cases for append-table write/read and primary-key table 
write/MOR
+   read paths.
+
+``paimon-parquet-format-benchmark``
+   Format-level cases that drive the Parquet writer and reader directly, 
without
+   catalog lookup, split planning, merge/sort or commit.
+
+Benchmarks are disabled by default.
 
 Build
 =====
@@ -29,13 +38,14 @@ Enable benchmarks when configuring CMake::
 
    cmake -S . -B build -DPAIMON_BUILD_BENCHMARKS=ON
    cmake --build build --target paimon-read-write-benchmark
+   cmake --build build --target paimon-parquet-format-benchmark
 
 Run all benchmark cases through CTest::
 
    cmake --build build --target benchmark
 
-Custom Options
-==============
+Table-level Custom Options
+==========================
 
 ``paimon-read-write-benchmark`` accepts Google Benchmark options plus the 
Paimon
 specific options below:
@@ -88,3 +98,97 @@ MOR read from an existing table::
        --paimon_source_table_path /path/table \
        --paimon_pk_columns=id \
        --benchmark_filter=BM_MOR_Read/4
+
+Parquet Format Benchmark
+========================
+
+``paimon-parquet-format-benchmark`` takes only Google Benchmark options. It
+generates its own data and writes it to a temporary directory, so it needs no
+source file or table.
+
+Two things shape how the results should be read:
+
+- Every axis is swept on its own rather than as a combined matrix, so each
+  case answers one question and a change can be attributed to it.
+- Writes go through the local FileSystem into a temporary directory, so
+  absolute numbers carry the cost of that path. Comparisons are meaningful
+  only under the same environment and methodology - the same machine, build
+  configuration and options - which is what makes a before/after comparison
+  useful.
+
+Writer cases (``BM_ParquetWrite_*``) cover flat primitives, ``VARCHAR`` at low 
/
+medium / high cardinality with and without file-level dictionary encoding,
+already dictionary-encoded ``VARCHAR`` / ``INTEGER`` input arrays against their
+flat equivalents, ``DECIMAL`` at precision 9 / 18 / 38, nested ``STRUCT`` /
+``LIST`` / ``VECTOR`` / ``MAP``, null density from 0 to 100 percent, rows per
+``AddBatch`` call, column count at a fixed row count, row group size, the
+writer memory threshold that triggers a byte-based row-group flush, and the
+codecs Parquet accepts - ``none``, ``snappy``, ``gzip``, ``brotli``, ``zstd``,
+``lz4_raw`` and ``lz4_hadoop``. Note that ``lz4`` is deliberately not among
+them: it resolves to Arrow's ``LZ4_FRAME``, which
+``parquet::IsCodecSupported`` rejects.
+
+The two dictionary axes are different questions. ``BM_ParquetWrite_String`` and
+``BM_ParquetWrite_StringNoDictionary`` vary whether the *file* is dictionary
+encoded; ``BM_ParquetWrite_Dictionary*`` vary whether the *input array* already
+is, which is what decides whether Arrow can pass indices through to Parquet or
+has to materialize them first.
+
+Reader cases (``BM_ParquetRead_*``) cover full scan, single-column projection,
+predicate-filtered reads at varying selectivity with page-index filtering on 
and
+off, skip-heavy reads driven by a strided selection bitmap, null density,
+``DECIMAL`` at precision 9 / 18 / 38, ``DOUBLE``, dictionary-encoded against
+plain-encoded files, rows per ``NextBatch`` call, and nested column reads.
+
+Every case reports ``ns_per_row`` next to ``bytes_per_row`` - ``file_bytes`` 
for
+writes, ``read_bytes`` for reads - so a change that trades CPU for size is
+visible in both directions. Read cases additionally report ``rows_read``,
+``batches``, and ``row_groups`` / ``row_groups_after_filter`` from the reader's
+own metrics.
+
+Compare filtered cases on ``ns_per_input_row`` and ``bytes_per_input_row``, not
+``ns_per_row`` and ``bytes_per_row``. The latter pair divides by the rows a 
case
+actually materialized, so pruning shrinks numerator and denominator together 
and
+they can rise even as the run gets faster; the ``_input_row`` pair divides by 
the
+rows the file holds, which every setting shares.
+
+``row_groups_after_filter`` counts row groups only. It does not show page-level
+pruning: on the ordered ``id`` column both page-index settings usually keep the
+same row groups, and the page-index gain shows up in ``rows_read``,
+``read_bytes`` and ``ns_per_input_row`` instead.
+
+A case that cannot run - an unsupported codec, a schema the reader rejects -
+calls ``SkipWithError`` and makes the process exit non-zero, so ``ctest -L
+benchmark`` fails instead of reporting a silent skip. Read cases also assert on
+the number of rows they materialized, so a fixture that stopped producing rows
+fails rather than looking fast.
+
+Because the benchmark is only compiled under ``PAIMON_BUILD_BENCHMARKS``, the
+format-layer assumptions it relies on are covered separately by
+``paimon-parquet-format-benchmark-test``, which builds with the normal test
+suite.
+
+Each read case scans a file that is generated once on first use and reused for
+the rest of the run, so a filtered run only pays to build the fixtures its own
+cases need.
+
+All Parquet writer cases::
+
+   paimon-parquet-format-benchmark --benchmark_filter=BM_ParquetWrite
+
+Page-index filtering at 1% selectivity, on and off - compare ``rows_read``,
+``read_bytes`` and ``ns_per_input_row`` between the two::
+
+   paimon-parquet-format-benchmark \
+       --benchmark_filter='BM_ParquetRead_Filtered/keep_pct:1/'
+
+Read batch size sweep, repeated for a stable comparison::
+
+   paimon-parquet-format-benchmark \
+       --benchmark_filter=BM_ParquetRead_BatchSize \
+       --benchmark_repetitions=5 \
+       --benchmark_report_aggregates_only=true
+
+Null density on both sides, to see what definition levels cost::
+
+   paimon-parquet-format-benchmark 
--benchmark_filter='Parquet(Write|Read)_Nulls'

Reply via email to