<!-- Canonical: https://lucioduran.com/blog/arm64-graviton4-server-migration-benchmarks
Language: en
Content-SHA256: 5b94272487902f8d1f7cd72a3bd61a770886df1f44f69ca71a5282b88fbbedbd
-->

Infrastructure

# Graviton4 ARM64 Migration Benchmarks: Real Production Performance Data

2025-11-229 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/arm64-graviton4-server-migration-benchmarks>)

## The Test Setup

To make these benchmarks meaningful, the hardware specifics matter:

- **x86 baseline**: [AWS](<https://aws.amazon.com/>) m7i.xlarge, 4 vCPUs (Intel Sapphire Rapids 4th Gen), 16GB RAM, EBS gp3
- **ARM64 target**: r8g.xlarge, 4 vCPUs (Graviton4, Neoverse V2), 32GB RAM, EBS gp3
- **Region**: us-east-1
- **OS**: Amazon Linux 2023, kernel 6.1
- **Testing**: Each benchmark ran 10 times, numbers are median with p5/p95 bounds

Yes, the r8g has more RAM. Instance families should be chosen based on actual workload needs, not synthetic benchmark symmetry. In production, the right instance fits the application, not a comparison chart.

## Node.js (v22.x): The Surprising Middle Ground

A representative [Node.js](<https://nodejs.org/>) service for benchmarking is an API gateway, Express.js, lots of JSON parsing, JWT validation, rate limiting, response transformation. Classic I/O-bound with bursts of CPU for crypto and JSON.

### HTTP Throughput (wrk, 100 concurrent connections, 60s)

| Metric | m7i.xlarge (x86) | r8g.xlarge (ARM64) | Delta |
| --- | --- | --- | --- |
| Requests/sec | 14,230 | 15,870 | +11.5% |
| Avg latency | 6.89ms | 6.21ms | -9.9% |
| p99 latency | 18.4ms | 14.7ms | -20.1% |
| CPU usage (avg) | 72% | 64% | -11.1% |

The p99 improvement is the most notable result. Graviton4's more predictable core performance (no turbo boost variance) gives you tighter latency distributions. In a microservices architecture, that tail latency compounding effect is huge.

### JSON Serialization (1MB payload, 10K iterations)

```javascript
// Benchmark: JSON.parse + transform + JSON.stringify
const payload = generateLargeJSON(1_000_000); // ~1MB
const serialized = JSON.stringify(payload);

console.time('json-roundtrip');
for (let i = 0; i < 10_000; i++) {
 const parsed = JSON.parse(serialized);
 parsed.metadata.processedAt = Date.now();
 parsed.items = parsed.items.filter(i => i.active);
 JSON.stringify(parsed);
}
console.timeEnd('json-roundtrip');
```

| Runtime | x86 | ARM64 | Delta |
| --- | --- | --- | --- |
| Node.js 22 | 34.2s | 31.8s | -7.0% |
| Node.js 22 + ` --max-semi-space-size=128 ` | 28.1s | 25.4s | -9.6% |

V8's JSON parser benefits from Graviton4's improved memory bandwidth. The semi-space flag matters more on ARM64 because the GC pressure profile is different, Graviton4's larger L2 cache (2MB per core vs 1.25MB on Sapphire Rapids) keeps more young-gen objects hot.

### The Node.js Gotcha: Native Modules

Several common npm dependencies lack pre-built ARM64 binaries:

- ` sharp `, had ARM64 binaries, worked perfectly
- ` bcrypt `, had ARM64 binaries, worked perfectly
- ` cpu-features `, **no ARM64 binary**, fell back to WASM, 3x slower
- ` re2 `, **no ARM64 binary**, build from source required ` python3 ` and ` g++ ` in the container
- ` canvas `, **no ARM64 binary**, required manual compilation with system deps

The ` cpu-features ` fallback is invisible, no warnings, no errors, just silently slower. It often surfaces only when JWT validation benchmarks come in worse than expected on ARM64, leading to the discovery that the WASM fallback is handling the feature detection internally.

Always check: ` npm ls --all | xargs -I {} npm info {} cpu `

## Go (1.23): Where ARM64 Really Shines

Go on ARM64 is a different story. The Go compiler has had first-class ARM64 support since Go 1.16, and the runtime is heavily optimized for it.

### HTTP API Benchmark (net/http, JSON CRUD, PostgreSQL backend)

| Metric | m7i.xlarge (x86) | r8g.xlarge (ARM64) | Delta |
| --- | --- | --- | --- |
| Requests/sec | 28,450 | 34,120 | +19.9% |
| Avg latency | 3.45ms | 2.88ms | -16.5% |
| p99 latency | 11.2ms | 8.1ms | -27.7% |
| Allocs/op | 42 | 42 | 0% |
| GC pause (p99) | 0.8ms | 0.6ms | -25% |

Twenty percent more throughput. That's not a rounding error. And the GC pauses are shorter because Graviton4's memory subsystem is genuinely better, higher bandwidth, lower latency to L2/L3.

### The GOARM64 Flag: Free Performance

This is the tip most people miss. Go 1.23 introduced ` GOARM64 ` for fine-grained ARM64 feature targeting:

```bash
# Default build (conservative, runs on any ARM64)
GOOS=linux GOARCH=arm64 go build -o service ./cmd/service

# Graviton4-optimized build (uses ARMv9 features including SVE2)
GOOS=linux GOARCH=arm64 GOARM64=v9.0 go build -o service ./cmd/service
```

The difference on a compute-heavy operation (a recommendation engine, heavy on float64 math):

| Build | Throughput | Latency p50 |
| --- | --- | --- |
| Default ARM64 | 12,340 ops/s | 4.1ms |
| GOARM64=v9.0 | 14,780 ops/s | 3.4ms |

That's 19.8% more throughput from a single environment variable. The ` v9.0 ` flag enables SVE2 (Scalable Vector Extension 2) instructions, which Graviton4 supports. For workloads with floating-point math, string processing, or cryptographic operations, this is free performance.

### Compiler Flags with Measurable Impact

Beyond GOARM64, these flags showed measurable differences on ARM64:

```bash
# PGO (Profile-Guided Optimization), works on ARM64 since Go 1.21
go build -pgo=default.pgo -o service ./cmd/service

# Results with PGO on ARM64:
# Without PGO: 34,120 req/s
# With PGO: 37,230 req/s (+9.1%)
```

PGO on ARM64 is slightly more effective than on x86 because the branch predictor on Graviton4 benefits more from the profile data. Collect a CPU profile from production (` runtime/pprof `), save it as ` default.pgo ` in your main package directory, and rebuild. It's genuinely free performance.

## Rust (1.82): The Beast on ARM64

Rust on Graviton4 is where things get exciting. LLVM's ARM64 backend is exceptionally mature, and Rust gives you the control to exploit it.

### Image Processing Service Benchmark

An image processing service doing real-time resizing and format conversion (WebP, AVIF) provides a SIMD-heavy, memory-bandwidth-intensive benchmark.

```rust
// Target Graviton4 specifically
// .cargo/config.toml
[target.aarch64-unknown-linux-gnu]
rustflags = ["-C", "target-cpu=neoverse-v2"]
```

| Operation | x86 (AVX2) | ARM64 (default) | ARM64 (neoverse-v2) | Delta vs x86 |
| --- | --- | --- | --- | --- |
| JPEG decode (4K) | 12.3ms | 14.1ms | 11.2ms | -8.9% |
| WebP encode | 28.7ms | 31.2ms | 24.8ms | -13.6% |
| AVIF encode | 89.4ms | 97.1ms | 78.3ms | -12.4% |
| Resize 4K→1080p | 8.4ms | 9.1ms | 7.2ms | -14.3% |

Look at the difference between default ARM64 and ` target-cpu=neoverse-v2 `. Without the target CPU flag, ARM64 is actually slower than x86 for SIMD-heavy workloads because the compiler generates conservative NEON code. With the flag, it uses SVE2 with wider vectors and the performance flips.

### NEON vs SVE2: What You Need to Know

ARM64 has two SIMD instruction sets:

- **NEON**: Fixed 128-bit vectors, available on all ARM64. Equivalent to SSE4.2.
- **SVE2**: Scalable vectors (128-512 bit, Graviton4 uses 128-bit but with additional instructions), available on ARMv9. Equivalent to AVX2/AVX-512 in capability, though not in raw vector width.

If you're using SIMD intrinsics directly in Rust:

```rust
// NEON (works everywhere)
#[cfg(target_arch = "aarch64")]
use std::arch::aarch64::*;

unsafe fn sum_neon(data: &[f32]) -> f32 {
 let mut acc = vdupq_n_f32(0.0);
 for chunk in data.chunks_exact(4) {
 let v = vld1q_f32(chunk.as_ptr());
 acc = vaddq_f32(acc, v);
 }
 vaddvq_f32(acc)
}
```

For most Rust code, you don't need intrinsics. The compiler auto-vectorizes when you set the target CPU correctly. But if you're porting x86 SIMD code, be aware that NEON and AVX have different semantics for some operations (notably: no direct equivalent of ` _mm_shuffle_ps `, you need ` vqtbl1q_u8 ` or similar).

## Cost Analysis: Four Months in Production

After 4 months in production, here are the real numbers:

### Compute Costs (14 services, us-east-1)

|  | x86 (m7i family) | ARM64 (r8g family) | Savings |
| --- | --- | --- | --- |
| On-Demand monthly | $4,847 | $3,878 | -20.0% |
| Reserved 1yr monthly | $3,105 | $2,484 | -20.0% |
| Spot monthly (avg) | $1,939 | $1,242 | -35.9% |

The Spot savings are the standout. [AWS](<https://aws.com/>) has significantly more ARM64 Spot capacity (fewer people using it), so interruption rates are lower and prices are more stable. Over a 4-month period, average Spot interruption rates on ARM64 measured 2.1% vs 5.8% on x86.

### Total Infrastructure Savings

Combining the performance improvement (which allows downsizing some instances) with the lower per-vCPU cost:

- **Before (x86, mixed On-Demand/Reserved)**: $3,740/month
- **After (ARM64, mixed Reserved/Spot)**: $2,180/month
- **Net savings**: $1,560/month (-41.7%)

That 41.7% is not just the ARM64 price difference, it includes instance rightsizing, since the same workloads need fewer vCPUs on Graviton4.

## Migration Checklist

A condensed migration checklist based on production experience:

1. **Container base images**: Switch to multi-arch or explicit ` arm64v8/ ` images. ` FROM node:22-slim ` already pulls the right arch automatically.
2. **CI/CD**: Add ARM64 runners. GitHub Actions has ` runs-on: ubuntu-24.04-arm ` now. If self-hosted, Graviton3 c7g instances make excellent CI runners.
3. **Native dependencies audit**: Run your test suite on ARM64 first. Check for WASM fallbacks, missing binaries, compilation requirements.
4. **Compiler flags**: Set target CPU for compiled languages. ` GOARM64=v9.0 ` for Go, ` target-cpu=neoverse-v2 ` for Rust, ` -march=armv9-a ` for C/C++.
5. **Monitoring**: Watch for performance regressions in specific operations. Crypto operations are commonly slower initially due to missing ARM64 OpenSSL engine configuration.
6. **Gradual rollout**: Use weighted target groups in your load balancer. Start with 10% traffic to ARM64, validate metrics, increase.

## Benchmark Limitations and Caveats

Every benchmark post (including this one) is inherently misleading because it measures specific workloads on specific configurations. Results will vary. Some areas are deliberately excluded because they would be misleading:

- **Memory-bound workloads**: The r8g has different RAM characteristics than m7i. Direct comparison is apples-to-oranges.
- **Single-threaded peak performance**: x86 turbo boost can hit 3.6GHz+. Graviton4 runs at a fixed 3.0GHz. For burst single-thread, x86 wins.
- **AVX-512 workloads**: If your code uses AVX-512 (ML inference, scientific computing), x86 has a genuine advantage. Graviton4's SVE2 is not a direct equivalent.

The right approach: run your actual production workload on both architectures with real traffic. Synthetic benchmarks tell you direction, not magnitude.

## Is It Worth It?

The 41% cost reduction alone justifies the roughly two weeks of migration work. But the less quantifiable benefit is operational: Graviton4's fixed-frequency cores deliver more predictable performance, which means autoscaling is more responsive and capacity planning is simpler.

For AWS-based workloads, Graviton4 evaluation is increasingly table stakes. The ARM64 ecosystem is mature enough that the migration is boring, and in infrastructure, boring is exactly the goal.

## Frequently asked questions

### Is Graviton4 always faster than equivalent x86 instances?

No. For single-threaded workloads with heavy AVX-512 usage, x86 can still win. Graviton4 excels at throughput-oriented, multi-threaded workloads and offers better performance-per-dollar in almost all tested scenarios.

### How difficult is the migration from x86 to ARM64?

For interpreted languages (Node.js, Python, Ruby), it's mostly painless, the runtime handles the architecture difference. For compiled languages, you need to cross-compile or build on ARM64. The gotchas are in native dependencies and container base images.

### What about Docker images for ARM64?

Most official Docker images now provide multi-arch manifests. For custom images, use Docker buildx with --platform linux/arm64. The biggest headache is third-party images that only provide amd64 builds.

## Tips

1. **Critical flag:** For Go on Graviton4, compile with ` GOARM64=v9.0 ` to unlock SVE2 instructions, default builds leave 15-20% performance on the table for compute-heavy workloads.
2. **Node.js trap:** V8's JIT on ARM64 doesn't auto-vectorize as aggressively as on x86. For tight numeric loops, consider ` --experimental-wasm-simd ` or offload to native addons.
3. **Cost trick:** Graviton4 Spot instances have 60-70% less interruption frequency than equivalent x86 Spot, AWS has excess ARM capacity. Use ` capacity-optimized ` allocation strategy.
4. **Testing must-do:** Always run the full test suite on ARM64 CI before migrating. Some native npm packages lack ARM64 binaries and silently fall back to slower WASM builds.

- arm64
- graviton4
- aws
- benchmarks
- performance
- migration
- nodejs
- go
- rust

[Back to Blog](<https://lucioduran.com/blog>)
