Arrow Flight shuffle transport on Graviton4

VecRuntime's columnar shuffle serves each executor's map outputs from an Arrow Flight server; a reduce task opens one DoGet per remote executor and decodes the Arrow IPC stream as it arrives (how it works). This page is every experiment on that transport between two Graviton4 nodes: how many rows per second one executor's reduce tasks can pull from another, and which of the transport's settings move it.

In short. With the defaults one executor pulls 31 M rows/s on r8gd.12xlarge and 26 M rows/s on m8g.4xlarge, held at about 10 Gbit/s by EC2's per-connection limit: the client keeps one TCP connection per remote executor. Two connections (spark.vecruntime.shuffle.flight.channelsPerPeer=2) lift it to 39 (r8gd, +28%) and 46 (m8g, +76%, the instance's 15 Gbit/s); past that the limit is the client's zstd decode (r8gd) or the NIC (m8g). Server threads, the HTTP/2 window and server backpressure do not matter once connections are not the limit; 1 MB messages help where the network has room (r8gd: 46 M rows/s with four connections) and nowhere else. At TPC-DS 1 TB on nine m8g.4xlarge none of this shows: the shuffle-heavy queries take 144.1, 143.9 and 142.9 s with the defaults, two connections and four connections with 1 MB messages, so the defaults stay.

Setup

ComponentConfiguration
BenchmarkFlightNetBench (benchmarks module): the FlightBench fixture -- two Flight servers standing in for two executors, each serving the map files of 16 map tasks of 1 M rows written by the real shuffle writer at 200 reduce partitions, zstd -- on one node, and reduce tasks (FlightBlockStream, the production client: one DoGet per server for the task's partition, batches decoded) on another. Rows: two longs, two ints, two doubles, a 50-value string and a near-distinct string.
NodesTwo nodes of one EKS node group in one availability zone, no placement group; server pod and client pod on different nodes (pod anti-affinity), one executor's cores each: 5 on r8gd.12xlarge (the 3 TB campaign's executors), 13 on m8g.4xlarge (the 1 TB campaign's). JDK 25, 8 GB heap, image of main 51c90ed4 (arm64).
ProtocolThe client first reads every partition once and checks every row the servers wrote comes back. Then, per combination, three runs of 30 s with concurrency reduce tasks in flight; the first run (cold JIT and connections) is dropped and the median of the other two reported. M rows/s is decoded rows; Gbit/s the bytes on the wire; CPU-s/GB is process CPU over wire bytes, client and server.
Settings sweptClient: spark.vecruntime.shuffle.flight.channelsPerPeer (gRPC channels, i.e. TCP connections, per remote executor), clientWindow (a fixed HTTP/2 flow-control window; 0 = gRPC's own, which grows with the measured bandwidth-delay product), reduce tasks in flight. Server, one changed at a time: threads, chunkBytes (bytes per gRPC message), backpressureBytes, and the shuffle's compression. All keys are in Configuration.

r8gd.12xlarge (48 vCPU Graviton4, up to 22.5 Gbit/s, 5-core pods)

Server defaults. M rows/s per reduce-side executor; green: at least 15 % above the default (one connection, gRPC window, 5 tasks), red: at least 13 % below.

connections per peerwindow5 tasks10 tasksGbit/sclient CPU-s/GBserver CPU-s/GB
1gRPC (auto)30.930.99.92.550.89
11 MB30.730.99.82.610.87
116 MB30.930.99.92.560.87
2gRPC (auto)39.446.112.62.630.87
21 MB39.846.512.82.740.91
216 MB39.947.212.82.600.88
4gRPC (auto)39.746.012.72.740.9
41 MB39.846.312.82.730.94
416 MB39.946.212.82.690.9

One server setting changed at a time (5 tasks):

server1 conn, gRPC window1 conn, 16 MB4 conn, gRPC window4 conn, 16 MBGbit/s (4 conn)client CPU-s/GB (4 conn)server CPU-s/GB (4 conn)
defaults (threads = cores, 4 MB messages)30.930.939.739.912.72.740.9
server threads x430.930.939.840.312.82.680.76
1 MB messages (chunkBytes=1m)30.930.946.546.814.92.690.61
16 MB messages (chunkBytes=16m)30.830.939.639.512.72.730.87
server backpressure 32 MB (backpressureBytes=32m)25.130.939.339.412.62.730.96
no compression (shuffle.compression=none)21.321.347.347.822.00.660.83

m8g.4xlarge (16 vCPU Graviton4, up to 15 Gbit/s, 13-core pods)

Server defaults. M rows/s per reduce-side executor; green: at least 15 % above the default (one connection, gRPC window, 13 tasks), red: at least 13 % below.

connections per peerwindow13 tasks26 tasksGbit/sclient CPU-s/GBserver CPU-s/GB
1gRPC (auto)26.226.28.42.831.35
11 MB30.930.89.92.760.79
116 MB30.930.99.92.730.81
2gRPC (auto)46.146.014.82.790.77
21 MB30.930.99.92.780.75
216 MB44.844.114.42.750.68
4gRPC (auto)46.446.314.92.900.72
41 MB46.145.714.83.000.79
416 MB46.346.314.92.840.69

One server setting changed at a time (13 tasks):

server1 conn, gRPC window1 conn, 16 MB4 conn, gRPC window4 conn, 16 MBGbit/s (4 conn)client CPU-s/GB (4 conn)server CPU-s/GB (4 conn)
defaults (threads = cores, 4 MB messages)26.230.946.446.314.92.900.72
server threads x430.830.946.246.314.82.910.78
1 MB messages (chunkBytes=1m)23.225.446.346.414.93.090.71
16 MB messages (chunkBytes=16m)30.926.146.346.414.93.060.73
server backpressure 32 MB (backpressureBytes=32m)27.630.946.346.414.82.940.77
no compression (shuffle.compression=none)21.321.331.931.914.90.850.85

What the numbers say

  • One connection per remote executor is the limit with the defaults. Every one-connection row sits at about 10 Gbit/s whatever the window, the threads or the number of tasks: EC2 caps a single flow outside a cluster placement group at 5 Gbit/s, and the client reads from two servers. A second connection per server lifts the rate on both instance types; a fourth adds nothing more.
  • Past that, the client's decode is the limit on r8gd and the NIC on m8g. At 2.6-2.8 CPU-seconds per GB of zstd stream, five r8gd cores decode about 15 Gbit/s, where the two- and four-connection rows stop; m8g.4xlarge's 13 cores have room, and its rows stop at the instance's 15 Gbit/s.
  • Without zstd the client spends a quarter of the CPU per byte, but the stream is about twice the bytes: r8gd reaches its 22 Gbit/s line rate and the most rows per second, m8g fills its NIC with fewer rows than with zstd. Compression stays the default; none pays only where the network has the headroom and the cores do not.
  • 1 MB messages cut server CPU per byte by about a third and, on r8gd with four connections, raise the rate where the network is not the bound; with one connection they are slower (more messages through one flow-control window). 16 MB messages, four times the server threads and server backpressure change nothing measurable; backpressure on one connection is slower.
  • The flow-control window matters only for a single connection on m8g, where a fixed window holds 10 Gbit/s that gRPC's own sometimes does not; with several connections every window gives the same rate.
Caveats. Two nodes, one pair of servers, 30-second runs: m8g.4xlarge's 15 Gbit/s is a burst rate, and a whole shuffle stage longer than the burst credits sees less. This measures one reduce-side executor against two map-side ones; in a cluster every executor serves and reads at once, so the 1 TB A/B below is the ruler for defaults.

TPC-DS 1 TB on 9 x m8g.4xlarge

9 x m8g.4xlarge (8 executors x 13 cores), image of main 51c90ed4 (arm64), no table statistics; legs alternated c1 c2 c4m c4m c2 c1, 2 iterations + 1 warm-up per leg; mean of the two legs' medians, seconds; green / red: 10 % faster / slower than the defaults. Identical results in every leg.

querydefaults (1 connection, 4 MB messages)2 connections per peer4 connections, 1 MB messages
q6730.731.231.4
q7822.523.022.6
q6414.414.213.9
q23a21.621.721.8
q14a36.536.734.9
q418.417.118.3
q31.51.61.4
q550.60.60.6
shuffle-heavy total (first six)144.1143.9142.9

No setting moves the shuffle-heavy queries by 10 %: the six total 144.1, 143.9 and 142.9 s, within 1 %, and the spread between the two legs of one setting (q14a 34.8-38.2 s with the defaults) is larger than the differences between settings. In a cluster every executor serves and reads at once over many connections, so the per-connection cap the two-node benchmark hits is not what holds a stage back; the defaults stay. The two-node numbers above still say where to look on hosts with fewer, larger executors or a faster NIC.

Reproducing

benchmarks/k8s/render-flight-net.sh <image> \
  "partitions=200 maps=16 rowsPerMap=1000000 threads=5" \
  "partitions=200 maps=16 rowsPerMap=1000000 seconds=30 runs=3 channelsPerPeer=1,2,4 clientWindow=0,1m,16m concurrency=5,10" \
  | kubectl apply -f -
kubectl -n bench logs job/flight-net-client     # one JSON line per run

Copyright 2026-2027 Angel Conde and the vecruntime contributors. Measured 2026-10-10/11.