Skip to main content

We Moved Cazpian's Spark Pools to ARM — 2x Faster Under Load

· 12 min read
Cazpian Engineering
Platform Engineering Team

Cazpian Spark pools on Graviton ARM64: median job 5,109 ms vs 9,953 ms on x86 under 100-way concurrency

Conventional wisdom says migrating a data engine to ARM is a performance gamble you take for the discount. We ran the experiment on our own Spark compute pools, and the result inverted the premise: under 100-way concurrency, the ARM64 pool ran every job roughly twice as fast as its x86 twin — at about 20% lower infrastructure cost.

We are publishing the numbers, but also the part benchmark posts usually cut: our first measurement said ARM was 8.5x slower, and that number was wrong in a way that had nothing to do with ARM. If you are planning an ARM migration for your data platform, the detour is probably worth more to you than the table.

The experiment

Two Spark compute pools on AWS Fargate, deliberately identical except for the chip:

x86 poolARM64 pool
driver4 vCPU / 16 GB4 vCPU / 16 GB
workers3 × 8 vCPU / 60 GB3 × 8 vCPU / 60 GB
Spark / Icebergsame versionssame versions
Native Query Accelerationactive, verified in the planactive, verified in the plan

The workload is the same one from our 100-concurrent-jobs benchmark: each job scans all 23 columns of a 199 MB Iceberg table (4.1 million rows) and computes a checksum aggregation — 100 jobs submitted concurrently through the control plane's job API, the same path a scheduler uses. We ran four bursts of 100, alternating which architecture went first to cancel warm-up effects.

"Verified in the plan" is doing real work in that table. Native acceleration fails soft: if the fast path cannot engage, the query silently falls back to standard Spark and still returns correct results. A pool in that state looks completely healthy. We checked the executed plans on both drivers before trusting a single timing — this paragraph is foreshadowing.

The results

Reliability first: 400 of 400 jobs succeeded, on both architectures, in every round.

Per-job execution time, 200 jobs per architecture:

x86ARM64
median (p50)9,953 ms5,109 ms
p9016,149 ms6,646 ms
mean10,171 ms6,388 ms

And per round, so you can see it is not an averaging artifact — ARM won both rounds, in both orders:

per-job p50round 1round 2
x8610,496 ms9,682 ms
ARM645,942 ms4,680 ms

The engine's own SQL timer tells the same story: roughly 2.4 s median on ARM versus 6.0 s on x86 under full load. Interestingly, a single job run in isolation is a tie — about 2.8 s on either chip. The ARM advantage appears specifically under concurrency: its latency distribution barely widens as 100 jobs pile onto the pool, while x86's roughly doubles. Neither pool was CPU-saturated (ARM peaked around 22% of its reservation), so this looks like the sustained-throughput behavior Graviton is known for rather than a headroom effect — we state that as a reading of the data, not a microarchitectural proof.

The cost line

Straight from public AWS Fargate pricing (us-east-1, on-demand, September 2026): this pool shape — 28 vCPU and 196 GB across driver and workers — costs about $2.00/hour on x86 and $1.60/hour on ARM64. That is the standard ~20% Graviton discount, and it now arrives with a per-job speedup instead of a tax.

The metric we threw out

Batch wall clock — first submit to last completion — ranged from 67.7 s to 98.7 s across the four rounds, and its best single round happened to be x86's. We excluded it from the verdict, and you should exclude it from yours: at this job size, wall clock measures how fast the orchestrator places 100 launcher containers, a pipeline that is identical (and, in our case, still x86) for both pools, and that varies ±40% run to run on its own. A batch whose median job takes 5 seconds posting a 96-second wall is telling you about container placement, not about the chip. If you A/B architectures on batch wall at small job sizes, you are benchmarking your scheduler.

The 8.5x detour, or: it is never the chip

Our first A/B produced a headline we very nearly believed: ARM64 was 8.5x slower on the engine's own clock. It took most of a day to discover that number was true, measured honestly — and meaningless. Three layers had to be peeled.

Layer one: the ARM pool was never accelerated. Our platform approves native acceleration per image digest, one digest per repository — and the x86 and ARM builds of the Spark image share a repository. Approving one silently un-approved the other. The ARM pool started, passed health checks, answered queries, and ran every scan on the slow path. The 8.5x compared accelerated x86 against unaccelerated ARM. The fix was making approval accept multiple digests; the lesson was older than the fix: read the executed plan before trusting any acceleration number.

Layer two: with acceleration approved, every ARM scan failed outright — a native S3 credential error. Then the same build's x86 sibling failed identically, which exonerated ARM and indicted the image. We diffed the two images down to the bone: all 300+ jars byte-for-byte identical, environments identical, every configuration delta tested key-by-key on live pools. Nothing explained it. Because nothing in the images was the cause.

Layer three: the timeline. Every "working" measurement predated — and every failure postdated — a cost-saving scheduler in our dev cluster stopping one background service at noon. That service turned out to sit at the bottom of a credential chain: the control plane delegated secret decryption to it, the catalog service therefore dropped the S3 credentials from the catalog it hands to Spark pools, and the native scan — whose only credential source is that hand-off — failed on every read. The standard JVM path falls back to the pool's IAM role, so ordinary queries kept working and every pool kept looking healthy. Three independent fail-softs composed into one hard failure, four services away from its cause.

We re-tested the "good" old image during the outage: it failed identically. We restored the stopped service: the "broken" new image ran at full speed. Both images were always fine. When two provably identical artifacts behave differently, stop diffing the artifacts and start diffing the timeline.

The checklist we wish we'd started with

  1. Verify the fast path in the executed plan, per architecture, before comparing anything. Fail-soft acceleration makes "healthy but silently slow" the default failure mode.
  2. Run your control at the same time as your candidate. Our x86 "baseline" was measured hours before the ARM runs, across an infrastructure change nobody clocked. Same-time controls would have collapsed the whole investigation to minutes.
  3. Alternate execution order and run multiple rounds. Our ARM advantage held in both orders; that is what let us publish it.
  4. Separate orchestration metrics from compute metrics. Per-job and engine-side timings compare chips; batch wall clock compares schedulers.
  5. Audit what happens to your pools on restart. We found (and are fixing) a path where a restarted ARM pool silently re-registers as x86 — the kind of regression that only shows up in the plan, per lesson one.
  6. Know your credential chain end to end. The native engine's S3 client did not fall back to the task role the way the JVM does. Whatever vends credentials to your fast path is part of your query path's availability.

The 1 TB rematch

Concurrent small jobs are one workload shape. Before calling the migration, we re-ran our TPC-DS 1 TB benchmark as a same-night A/B: two freshly created pools, four workers and 64 worker vCPUs each, the same build of our runtime on both sides, only the architecture differing. All 103 queries, one shot each, no warm-up, zero failures on either side.

TPC-DS 1 TB rematch: ARM64 2,926 s vs x86 4,494 s, within 5% of the published Trino figure on the 99-query basis

x86ARM64
Total, 103 queries4,494 s2,926 s
Median query30.6 s20.3 s
Slowest query266.6 s125.6 s
Queries faster102 of 103

ARM64 ran the full sweep 1.54× faster — and did the same work with 1.8× less executor CPU time and half the garbage collection, which is the honest chip-level signal underneath the wall clock.

Every query, both architectures

Summaries hide things, so here is the whole sweep — one execution per query, wall clock, same night, same build, the only difference being the chip. Speedup above 1.00× means ARM64 was faster.

queryx86 sARM64 sspeedupqueryx86 sARM64 sspeedup
q114.610.81.35×q4928.918.51.56×
q220.316.41.24×q5059.532.41.83×
q317.812.41.43×q5141.028.21.46×
q4177.3111.41.59×q5215.310.51.46×
q535.725.41.40×q5318.313.91.32×
q617.211.11.55×q5436.620.81.76×
q733.523.91.40×q5513.99.71.43×
q813.810.91.26×q5630.622.01.39×
q971.352.31.36×q5719.412.91.50×
q1045.934.31.34×q5840.527.81.46×
q11106.868.61.56×q5939.830.31.31×
q128.96.91.29×q6034.525.11.38×
q1319.513.61.44×q6172.440.11.80×
q14a105.267.21.56×q628.76.21.39×
q14b108.569.11.57×q6317.212.21.42×
q1533.721.01.60×q64135.482.91.63×
q1629.820.01.49×q6546.530.11.54×
q1751.533.71.53×q6620.814.11.47×
q1830.518.91.61×q67150.986.31.75×
q1944.927.01.66×q6833.420.31.64×
q2012.49.11.36×q6932.624.71.32×
q214.63.61.28×q7032.319.91.62×
q2217.313.61.27×q7134.523.71.45×
q23a153.9104.31.48×q72266.6125.62.12×
q23b160.2104.91.53×q7313.28.31.60×
q24a108.865.31.67×q7482.455.61.48×
q24b111.067.31.65×q75113.187.11.30×
q2545.431.81.43×q7626.219.71.33×
q2612.39.71.26×q7727.021.31.26×
q2726.918.81.43×q78124.487.21.43×
q2864.444.91.44×q7931.922.41.42×
q2952.433.81.55×q8058.038.51.50×
q3011.08.01.37×q8111.07.61.45×
q3188.258.01.52×q8238.823.71.64×
q3212.89.21.39×q835.44.21.26×
q3333.923.61.44×q844.33.01.40×
q3414.08.91.57×q8525.019.31.29×
q3535.327.41.29×q865.54.51.22×
q3620.513.21.56×q8769.644.41.57×
q3723.615.71.51×q8853.733.71.59×
q3870.944.61.59×q8922.617.71.28×
q39a10.97.31.50×q904.44.90.90×
q39b9.65.51.74×q917.95.71.39×
q4013.99.71.44×q926.86.41.06×
q411.80.72.49×q9370.338.21.84×
q4215.69.01.73×q9418.914.51.31×
q4317.511.11.58×q9559.340.31.47×
q4420.815.71.33×q968.25.41.51×
q4521.513.61.58×q9743.930.31.45×
q4635.122.11.58×q9833.222.61.47×
q4751.033.41.53×q9913.28.81.49×
q4822.213.61.63×

One hundred and two of the hundred and three land above 1.00×. The lone exception is q90 at 0.90×, and the biggest wins cluster exactly where the work is heaviest — q41 at 2.49×, q72 at 2.12×, the multi-terabyte sorts all north of 1.4×.

That result moves the comparison we published in the 1 TB post. On the same 99-query basis and against the same third-party-measured Trino figure (2,552 s), our x86 run stood 1.49× slower. The ARM64 run comes in at 2,679 s — within 5% of Trino, on compute that costs a fifth less. The caveats from that post still apply — published figure, different hardware, local NVMe on their side — and the only unarguable comparison remains running both engines ourselves, which is now firmly on the list. But the trend line is the story: stock Spark measured 3.5× slower than Trino on identical hardware; Native Query Acceleration brought us to 1.49×; the same acceleration on ARM64 brings us to 1.05×, at lower cost, without giving up Spark's ecosystem or open Iceberg tables.

What we did not test

Python-UDF-bound jobs get their own A/B before we migrate them. Both benchmark nights ran on a dev cluster, not a production distribution, and single-shot queries carry no confidence intervals. Our launcher containers stayed x86 throughout — per-job container placement was identical for both pools by construction.

The verdict

ARM64 stays. Same reliability, roughly half the per-job latency under the concurrency that actually matters to us, a 1.54× faster 1 TB benchmark that lands within 5% of the published Trino figure, and a fifth off the infrastructure bill — with the small-job economics we keep writing about, the discount compounds on every one of those hundred containers. The gamble conventional wisdom warned us about turned out to be the status quo.