Performance
Measured against Ray and Rust, including the runs it lost
Every number here comes from runs on AWS, with the answers of all three frameworks checked against each other. The workload is the eight taxibench queries: scans, filters, sums and group-bys by zone, over NYC yellow taxi trips 2011–2024 as an Iceberg table in S3: 1,438,321,685 rows in 416 Parquet files (19.5 GB), written by 168 appends. Machines: AWS c7g.4xlarge (16 Graviton3 vCPUs, 32 GiB), us-east-2, k3s, 16 task slots per node. Runs from 1 November 2026.
Rust is iceberg-rust 0.10 with parquet and tokio, distributed by a small driver that hands each worker one file at a time. Ray is Ray with PyIceberg and pyarrow, one Ray task per file. Each is written the way its users would write it, and each gets the same nodes. The numbers are early: one run per configuration, on one day, and the comparison frameworks moved by about 10% between two runs of the same thing.
Distributed, 3 nodes ms, the eight queries, median pass
| Run | Dista | Rust | Ray |
|---|---|---|---|
2 passes | 12,008 | 28,744 | 38,570 |
5 passes | 11,040 | 18,377 | 21,275 |
Each query
| Query | Dista | Rust | Ray |
|---|---|---|---|
q1_scan_count | 1,981 | 2,306 | 4,128 |
q2_month_range | 157 | 429 | 409 |
q3_payment_sum | 1,599 | 3,347 | 4,168 |
q4_tip_ratio | 1,980 | 3,538 | 3,528 |
q5_top_zones | 1,889 | 3,481 | 3,483 |
q6_zone_revenue | 247 | 468 | 493 |
q7_wide | 1,001 | 1,074 | 1,219 |
q8_selective | 2,169 | 3,756 | 3,603 |
Three worker nodes, 16 task slots each, one task per data file, in two runs on separate clusters the same day. With 2 passes the median is the mean of both, so the slow first pass counts in it, and Rust and Ray lose more on that pass than Dista; with 5 the median is a warm pass. Even warm, Rust and Ray were slower on the first cluster: the clusters' own variation. Each query is from the 5-pass run. Every answer matches across the three frameworks: row counts exactly, sums to floating-point rounding. Results: matrix-2026-11-01 and gate-2026-11-01-readpath
Distributed, 3, 6 and 12 nodes ms, median of 5 passes
| Run | Dista | Rust | Ray |
|---|---|---|---|
3 nodes | 11,040 | 18,377 | 21,275 |
6 nodes | 7,184 | 10,267 | 12,737 |
12 nodes | 3,751 | 6,682 | 9,955 |
The eight queries' total on 3, 6 and 12 worker nodes; answers identical across every run. On 12 nodes, each framework's best of three runs: Ray's moves by up to 50% between runs. Results: matrix-2026-11-01
One process, 16 threads ms, the eight queries
| Dista | Rust | Ray (PyIceberg + pyarrow) | |
|---|---|---|---|
| Total | 56,380 | 96,233 | 94,504 |
Each query
| Query | Dista | Rust | Ray (PyIceberg + pyarrow) |
|---|---|---|---|
q1_scan_count | 7,828 | 15,597 | 9,192 |
q2_month_range | 315 | 830 | 446 |
q3_payment_sum | 9,229 | 18,365 | 14,937 |
q4_tip_ratio | 10,663 | 18,360 | 22,689 |
q5_top_zones | 15,309 | 19,907 | 16,625 |
q6_zone_revenue | 747 | 919 | 1,233 |
q7_wide | 888 | 1,026 | 706 |
q8_selective | 11,401 | 21,229 | 28,676 |
The head node alone, no workers, every framework on its 16 cores: Dista's scan with 16 workers, Rust's multi-threaded tokio runtime with one task per core, pyarrow's default thread pools. Answers identical across the three. Rust's numbers are from local-2026-11-01-2, a re-run. Results: local-2026-11-01
One process, one thread, 8 files' reads in flight ms, the eight queries
| Dista | Rust | Ray (PyIceberg + pyarrow) | |
|---|---|---|---|
| Total | 211,887 | 208,341 | 184,327 |
Each query
| Query | Dista | Rust | Ray (PyIceberg + pyarrow) |
|---|---|---|---|
q1_scan_count | 28,022 | 34,318 | 17,537 |
q2_month_range | 391 | 834 | 533 |
q3_payment_sum | 38,329 | 39,690 | 29,969 |
q4_tip_ratio | 50,365 | 42,206 | 44,095 |
q5_top_zones | 41,525 | 46,057 | 31,709 |
q6_zone_revenue | 1,718 | 1,696 | 1,945 |
q7_wide | 1,132 | 1,097 | 1,211 |
q8_selective | 50,405 | 42,443 | 57,328 |
One decoding thread each, with up to 8 files' reads in flight: Dista's files_at_once, iceberg-rust's concurrency limits on a current-thread runtime, pyarrow's I/O pool next to one CPU thread. Dista is level with Rust and 15% behind Ray; what's left is decode speed on one core. Results: local-2026-11-01
The second benchmark: a shuffle
The taxi queries spend most of their time reading Parquet. This one spends it in user code and in moving data between tasks: every trip grouped by pickup zone, dropoff zone and hour of day (1,044,000 groups), each group adding up its trips, speeds, tip ratios and fares. Map tasks read one data file each and split their groups by key into one partition per reducer; each reducer takes its partition from every map task and adds them up. Dista and Ray only, on the same table and nodes, from 2 November 2026. The query and its answer.
The shuffle, distributed on 3, 6 and 12 nodes ms, median of passes
| Run | Dista | Ray |
|---|---|---|
3 nodes | 6,291 | 14,537 |
6 nodes | 3,795 | 11,407 |
12 nodes | 3,976 | 10,570 |
One map task per data file, which splits its groups into one partition per reducer; one reducer per task slot, up to 96. More reducers made Dista about twice as slow, since 416 map tasks then make tens of thousands of partitions; Ray's best on 12 nodes, with 48 reducers, is 9,644 ms. The 12-node row is from shuffle-reducers-2026-11-02. Every answer matches both one-process references: groups and trips exactly, sums to about 1e-13. Results: shuffle-matrix-2026-11-02-2
The shuffle, one process ms
| Run | Dista | Ray (PyIceberg + NumPy) |
|---|---|---|
16 threads | 15,897 | – |
8 threads | 26,650 | 114,993 |
1 thread | 105,656 | 112,448 |
The head node alone. Each Dista thread reads whole files into arrays of its own, added up at the end. Ray's time barely moves with threads: its decoding uses them, but its per-batch NumPy work runs on one. Ray's 16- and 12-thread runs failed, likely from memory pressure on the head node, which also runs Kubernetes. Results: shuffle-local-2026-11-02-3
Where the time went
Dista records a timeline of every task, which opens in Perfetto. Before the read path, a file task waited on S3 for about 95% of its time: decoding the same file from local disk takes 12 to 40 ms. Planning each query read the table's 168 manifests one after another, and every task re-read the table's metadata. Now the footer comes in one read, a file's column chunks and a plan's manifests are fetched at once, and the metadata goes out with the task. Rust's iceberg-rust reader already did the first two.
| From the timeline | before | after |
|---|---|---|
| A file task, median | 691 ms | 177 ms |
| A file task, p90 | – | 243 ms |
| Planning one query | ~4,000 ms | 295 ms |
| Submitting a query's 416 tasks | 800–1,100 ms | 3 ms |
| S3 requests, one-column read | before | after |
|---|---|---|
| Table metadata (133 KB, 168 snapshots) | 1 | 0, sent with the task |
| Footer | 2: trailer, then footer | 1: a 512 KiB tail |
| Column chunks, 4 row groups | 4, one after another | 4 at once |
Building and shippingcold build on the head node
| Image | size, MB: gzip (unpacked) | whole build (s) | dependencies (s) | compile (s) |
|---|---|---|---|---|
| Dista | 32 (93) | 122 | 35.1 | 54 |
| Rust | 41 (132) | 170 | 93.4 | 57.9 |
| Ray | 204 (607) | 44 | 17.5 | – |
Dista's image is distroless: the binary and the libraries it links, one image for head, workers and driver. Ray's needs no compile and is the largest.
The runtime itself
Tasks through one head
about 160,000 a second
empty tasks, flat from 4 to 192 workers (bench/fanout.mojo, local)
Actor calls
1,000 ordered calls in about 22 ms
one counter actor, local
The 3-node AWS gate
about 13 minutes, about $1.50
a head and 3 workers: builds, three frameworks, two passes, teardown