Running the benchmarks

Kernel microbenchmarks (JMH):

mvn -DskipTests package
java --add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED \
     -jar benchmarks/target/benchmarks.jar "Compare|Compact|Agg"

The kernel tests can run with the lane counts of other platforms, emulated (slowly) on any machine, which is how the AVX2 and AVX-512 code paths (compress, 256-entry shuffle tables, 8-lane masks) are kept honest on a laptop:

mvn -pl kernels test -Dvector.jvm.args="--add-modules=jdk.incubator.vector --enable-native-access=ALL-UNNAMED --sun-misc-unsafe-memory-access=allow -Dvecruntime.vectorBits=512"

TPC-H, all 22 queries over the eight tables (decimals replaced by doubles, generated with DuckDB):

brew install duckdb
benchmarks/scripts/gen-tpch.sh 1                      # benchmarks/data/sf1/<table>  (lineitem: 6M rows, 207 MB)
benchmarks/scripts/gen-tpch.sh 10                     # benchmarks/data/sf10/<table> (lineitem: 60M rows, 2.1 GB)
benchmarks/scripts/gen-tpch.sh 1 benchmarks/data --decimals   # benchmarks/data/sf1-decimal: real DECIMAL(15,2) columns
mvn -DskipTests install
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
benchmarks/scripts/run-tpch.sh benchmarks/data/sf1    # spark + vector, q1..q22 (--queries q1,q6 for a subset)
COMET_JAR=/path/to/comet-spark-spark4.1_2.13-1.0.0.jar \
benchmarks/scripts/run-tpch.sh benchmarks/data/sf10   # + comet-scan, comet-scan-vector, comet-scan-vector-shuffle, comet
benchmarks/scripts/gen-iceberg-mor.sh benchmarks/data/sf1   # Iceberg merge-on-read variants of lineitem (docs/iceberg.md)
benchmarks/scripts/run-tpch.sh benchmarks/data/sf1 spark,vector --iceberg benchmarks/data/iceberg --variant sf1.pos_10 --queries q1,q6,probe-count,probe-sum,probe-group

TPC-DS, the 99 queries (103 with the a/b variants) over the 24 tables, keeping the real DECIMAL(7,2) and DATE columns; the query text is Spark's own (tpcds/q*.sql from the spark-sql tests jar, the files its plan-stability suite runs), so each query's plan is the one Spark's optimizer is tested against:

benchmarks/scripts/gen-tpcds.sh 1                     # benchmarks/data/tpcds-sf1/<table> (store_sales: 2.9M rows)
benchmarks/scripts/run-tpcds.sh benchmarks/data/tpcds-sf1                      # spark + vector, q1..q99 -> benchmarks/results/tpcds
benchmarks/scripts/run-tpcds.sh benchmarks/data/tpcds-sf1 spark,vector --queries q10,q35,q45
benchmarks/scripts/run-tpcds.sh --report

On a cluster the same runners take the session spark-submit built (--cluster), the tables from a base URI or a catalog (--tables s3a://bucket/tpcds/sf1000/parquet, --tables catalog:db), and write one .jsonl per run to any Hadoop file system (--out s3a://...), each row carrying Spark's stage metrics for the query (executor time, GC, shuffle bytes, spill, peak memory) beside the plan and the operator counts; benchmarks/scripts/submit-cluster.sh <config> <tables> <dataset> <out> turns a configuration into the spark-submit line, and run-tpcds.sh --cluster-report <out> writes the report in the layout of the data-on-EKS Comet benchmark (summary, speedup distribution, regressions with their stage evidence, per-query table, environment) -- see benchmarks/k8s/README.md.

The benchmark configurations

The named configurations are defined twice and kept in step by hand -- TpchRunner.Configs for the local harness and the ENGINE arrays of benchmarks/scripts/submit-cluster.sh for a cluster (the runner warns when the session it runs in disagrees with the configuration it is labelled with):

configuration what the session sets
spark nothing: plain Spark, its vectorized Parquet reader, its sort-based shuffle
vector spark.plugins=io.vecruntime.spark.VectorPlugin; spark.vecruntime.exec.strictFloatingPoint=false (Comet's rounding; see the note under Aggregation); spark.vecruntime.exec.sortMergeJoin.mode=auto; spark.sql.parquet.enableVectorizedReader=true (Spark's default, made explicit -- our operators consume its batches, the row reader would make every plan fall back); spark.sql.columnVector.offheap.enabled=true (#403: the reader writes Arrow's fixed-width layout into native memory and the adapter wraps those lanes in place instead of copying them)
vector-shuffle vector plus spark.shuffle.manager=org.apache.spark.sql.vecruntime.shuffle.VectorShuffleManager and spark.vecruntime.shuffle.enabled=true: our columnar exchange (#288)
vector-shuffle-strict vector-shuffle with strictFloatingPoint=true (bit-identical double sums)
comet-scan-vector-ourshuffle vector-shuffle with Comet's plugin and scan (spark.comet.enabled, spark.comet.scan.enabled, every spark.comet.exec.* operator off, spark.memory.offHeap.enabled with OFFHEAP, 32 g)
comet-scan-vector-shuffle, hybrid, comet Comet's scan and native shuffle under our operators; the same with the mixed pass (spark.vecruntime.comet.mixed.enabled); pure Comet

On the cluster (benchmarks/k8s/run-matrix.sh <tables> <dataset> <out> <image> [runner args], CONFIGS="spark vector-shuffle" selects the configurations) the executor properties are environment variables of render-run.sh, and the 1 TB runs in docs/results.md use: EXECUTORS=8, EXEC_CORES=13, EXEC_MEM=30g (heap), EXEC_OVERHEAD=20g, DIRECT_MEM=30g (-XX:MaxDirectMemorySize, where our Arrow batches and the shuffle's buffers live -- see Memory tuning), DRIVER_CORES=2, DRIVER_MEM=4g, KEEP_EXECUTORS=1, one iteration per query. From 2026-09-26 the 1 TB runs pass only --conf spark.sql.shuffle.partitions=300 in SUBMIT_ARGS and leave AQE at Spark's defaults: no spark.sql.adaptive.advisoryPartitionSizeInBytes override (64 MB) and no spark.sql.adaptive.coalescePartitions.minPartitionNum. The pages published before then used advisoryPartitionSizeInBytes=128m and minPartitionNum=208, as their configuration sections say. EXEC_JAVA_OPTS appends executor JVM options (a JFR recording: -XX:StartFlightRecording=delay=55s,duration=60s,filename=/tmp/exec.jfr,settings=profile, copied out of the executor pods with kubectl cp before the application ends), SUBMIT_ARGS extra --conf pairs for one run (--conf spark.vecruntime.sort.spillBytes=4g). Read a run's medians from the driver log before the next run of the same configuration replaces the pod, and know that the container log rotates at 10 MB; the .jsonl results in <out> and run-tpcds.sh --cluster-report are the durable record.

Each configuration runs in its own JVM and appends its measurements to benchmarks/results/<config>.jsonl (benchmarks/results/tpcds/<config>.jsonl for TPC-DS). The runner then rewrites two reports from every .jsonl file, one section per dataset (sf1, sf10, ...): benchmarks/results/results.md and a self-contained benchmarks/results/results.html with bar charts (median, p90 whisker, speedup against plain Spark), an accelerated-operators table per query (operators executed by our kernels or Comet over the operators that count, the same classification the Vector Acceleration UI tab uses, so each closed compatibility gap shows up as a query moving), the operators found in each final plan with the reason for every one the planner declined to convert, and a checksum proving all configurations returned the same rows (to 10 significant digits). Regenerate them without benchmarking with benchmarks/scripts/run-tpch.sh --report. See docs/results.md for numbers measured on an Apple M3 Pro; at SF10, Q1 runs 1.64x faster than Spark over Spark's own scan and 1.84x over Comet's scan and shuffle (Comet end to end: 1.62x), while the highly selective Q6 stays at 0.86x over Spark's scan. The five-configuration matrix on an x86 host after #311 (results.md, "The five configurations"): over the 22 TPC-H SF10 queries pure Spark 86.5 s, our operators over our columnar shuffle 59.6 s (1.45x), with Comet's scan in front 53.4 s (1.62x), hybrid 44.0 s (1.97x), native Comet 40.6 s (2.13x); TPC-DS SF1 runs 80-87% of operators on our kernels (85-87% with Comet's scan) with every checksum equal to Spark's, and at that scale is a coverage harness rather than a speed benchmark.

When a result is not what you expected, profile before theorising. Java Flight Recorder attaches to a benchmark JVM with one environment variable, and RESULTS_DIR keeps the profiling run out of the report:

JVM_EXTRA="-XX:StartFlightRecording=filename=/tmp/q1.jfr,settings=profile,dumponexit=true" \
RESULTS_DIR=/tmp/profiling \
benchmarks/scripts/run-tpch.sh benchmarks/data/sf10 comet-scan-vector --queries q1 --warmup 2 --iterations 5
$JAVA_HOME/bin/jfr view hot-methods /tmp/q1.jfr
$JAVA_HOME/bin/jfr print --events jdk.ExecutionSample --stack-depth 12 /tmp/q1.jfr   # callers of a hot frame

Or as one command: benchmarks/scripts/profile-query.sh benchmarks/data/sf10 comet-scan-vector q1 runs, records and summarises (benchmarks/scripts/jfr-summary.sh <file.jfr> summarises any recording: hot methods, the callers of the JDK-internal MemorySegment frames, the plugin's own frames by self time, allocation, GC, waits and native methods -- text you can paste into an issue).

Every performance finding in this project came out of such a recording rather than out of the median alone: the masked-reduction aggregate, triple string decoding, the two-lane shuffle table, and most recently a zero-copy configuration that was slower than a copying one because Comet's scan delivers plain strings where Spark's delivers dictionaries (MemorySegment.mismatch set-up was a third of the aggregate). The [tpch] lines the runner prints with per-operator kernel time are the first thing to read; the JFR hot-method list and the callers of any JDK-internal frame near the top (checkValidStateRaw, checkBounds) are the second.