Skip to content

Fast Iceberg reader and writer comparisons

Use this loop while changing the connector. It compares the production file-task boundaries with the released Flink Iceberg reader/writer, without starting a Flink cluster. The default process takes about 8–9 seconds on the recorded benchmark host, including validation; an unchanged build check takes about 0.04 seconds. Initial release compilation and dependency preparation are separate, one-time costs. Recorded before/after comparisons retain full ranges, profiles and focused streaming confirmation.

# JDK 17, Rust and Maven required; run from a checkout.
bin/bench-iceberg-io.py --output target/io-before
# Edit connector Java or Rust, then compare with the previous native result.
bin/bench-iceberg-io.py --baseline target/io-before/observations.jsonl
# Focus on one side or change the workload.
bin/bench-iceberg-io.py --cases read --rows 65536 --stripes 1
bin/bench-iceberg-io.py --cases write --batch-rows 1024 --width 264

The runner builds release + mimalloc libraries and prepares the Java dependency classpath once. Subsequent connector Java edits compile directly with javac; connector Rust edits use incremental Cargo. Core Java or dependency changes refresh the Maven preparation. Libraries are staged under immutable content-derived paths, so rebuilding does not overwrite a library mapped by a JVM. --prepare refreshes the full cached preparation explicitly. Ordinary runs verify source fingerprints before executing; they do not silently benchmark stale connector classes.

By default each implementation receives 262,144 deterministic rows with six scalar columns: long ID, nullable integer/double/Unicode string/boolean and a timestamp. The table has four bucket partitions. Both sides use Zstd level 3, 128 MiB row groups, 1 MiB pages, 2 MiB dictionaries and a 512 MiB rolling target. Source fetch batches match the requested 8,192 rows on both sides. Two warmups and five measured runs alternate implementation order. Controls include --rows, --batch-rows, --width, --partitions, --stripes, --target-bytes, --warmup, --runs and --cases.

Reading reuses immutable Java-written input files across invocations: four files per partition by default. Both readers consume identical Java-planned file tasks and perform real local FileIO. Stock uses RowDataReaderFunction; native uses the production Arrow reader function, including JNI, schema/metadata translation, decode, C Data import, file close and batch recycling. Native/fallback task counters must show all tasks decoded natively. The timed consumer counts decoded records. Separate untimed scans verify every field and detect duplicate/missing IDs.

Writing starts with equivalent prepared RowData and Arrow inputs, each in its normal engine representation. It runs the real Java task writer factory, and the native leg runs the production Arrow batch writer and appender. Only the Flink operator's metric initialization is supplied directly. Writer creation, Java row-view traversal/partitioning, rolling checks, JNI, encoding, close and file metrics remain timed. Preparing retained fixture views is outside timing. Every produced file is then decoded by stock Java Iceberg, every value/ID checked, and the footer required to identify the expected Java or Rust encoder. Native handles and additional Arrow/task reservations must be released after each trial. Output payload files are deleted after validation; raw observations, ranges, build fingerprints, library hashes and logs remain under target/iceberg-io/ or --output.

The optional --cases dynamic-route compares the production dynamic generator with its original uncached implementation, including borrowed Arrow row views. It checks every table/branch destination and excludes the host processor, catalog, shuffle and file writing. Candidate results retain gains, inconclusive writer timings and rejected prototypes.

The optional --cases dynamic-key isolates the released dynamic sink's partitioned writer-key selector. It compares original Java selection with bounded exact-result reuse for bucket combinations whose maximum cardinality, including nulls, fits 1,024 entries, including borrowed Arrow row traversal on the native side, and checks every selected key. It retains the host table salt and writer-parallelism mapping. This is separate from dynamic-route (the user's generator): neither case includes catalog updates, the full processor, shuffle, file encoding, checkpoints or commits.

For CPU, allocation or wall profiles, use a released async-profiler installation:

bin/bench-iceberg-io.py --cases write --profile itimer --profiler /path/to/profiler.sh
bin/bench-iceberg-io.py --cases read --profile alloc --profiler /path/to/profiler.sh

For hash-routing edits, --cases route isolates Java partition transforms and key-group assignment, comparing the stock row selector with the production Arrow routing helper. It verifies every destination against stock. This case takes about two seconds including validation on the recorded host. It excludes Rust batch splitting, exchange transport and key-group-to-writer range mapping.

Each profiled trial retains a thread-labelled collapsed profile. Profiling surrounds the timed production boundary and excludes validation. Profiled durations are diagnostic; use unprofiled runs for performance comparisons. More rows or repetitions provide more samples when a boundary is too short for useful sampling.

This is a warm local file-I/O benchmark, not a full Flink job or a Nexmark result. It excludes discovery, source fetcher queues, network/Arrow IPC shuffle, query state, checkpoint barriers, table commits and recovery. The default writer is append-only; keyed delete behavior, dynamic routing, range distribution and recovery stay in the streaming SQL/connector suites. Once a change wins here, run a focused release Nexmark confirmation and the relevant SQL/recovery tests. Rerun the complete matrix when preparing the final end-to-end comparison, not after every edit.