Skip to main content

ARM vs x86 for AI Workloads

Reading time: ~35 min · Interview relevance: High · Target roles: ML Engineer, MLOps Engineer, Infrastructure Engineer

A team running 200 inference servers for a recommendation model switches from c5.9xlarge (x86, Intel Xeon) to c7g.8xlarge (ARM, AWS Graviton3). Throughput improves 20%. Cost drops 40%. Power consumption drops 35%. The model runs the same PyTorch code, unchanged, just compiled for a different ISA. Understanding why this works - and when it does not - is the difference between paying cloud bills and optimizing them.

The Infrastructure Decision That Changed the Industry

In 2018, Amazon Web Services began shipping a quiet but consequential product: the Graviton processor, an AWS-designed ARM chip for their cloud infrastructure. Initial performance was modest. But in 2021, Graviton3 arrived with dramatically improved performance and, more importantly, Amazon made an aggressive pricing claim: Graviton3 instances cost 20-40% less than equivalent x86 instances, with better price-performance for most workloads.

This landed like a grenade in the ML infrastructure world. ML inference - running a trained model to serve predictions in production - runs 24/7 at massive scale. An inference cluster that costs 5millionperyearonx86mightcost5 million per year on x86 might cost 3 million on ARM. That $2 million difference per year funds model research, experimentation, and headcount. Infrastructure costs are not a side issue for AI companies; they are often the binding constraint on how ambitious the team can be.

The story is not simply "ARM is cheaper." It is more nuanced. x86 dominates training because NVIDIA's GPU ecosystem (with CUDA, cuDNN, and cuBLAS) is overwhelmingly x86-centric. Training large models on ARM CPUs is impractical today. But inference is different: you can run a trained model on any hardware that the ML framework supports. PyTorch and TensorFlow both have mature ARM backends. And on ARM, you are not just saving money - you are often getting lower latency at the same or better throughput, because the chip was designed to be efficient, not to win single-threaded benchmarks.

Apple Silicon tells a parallel story with different economics. The M1, M2, and M3 chips run ML inference with extraordinary efficiency because they solve the memory bandwidth problem differently. Instead of having separate CPU and GPU memory, unified memory is shared by both. A 16GB M2 has 16GB accessible to both CPU and GPU simultaneously. This eliminates the PCIe transfer bottleneck that makes GPU inference expensive on discrete GPU hardware. For model sizes between 4B and 13B parameters (which fit in 16-48GB), Apple Silicon offers compelling inference performance per dollar for local and edge deployments.

This lesson covers the architectural foundations behind these real-world tradeoffs. We will examine ISA design philosophy, the ARM power model, specific vector instruction capabilities, ML framework support, and how to design infrastructure that uses each architecture where it excels.

Why This Exists - The RISC vs CISC Divergence

In the 1970s and 1980s, computer architects faced a fundamental design choice: how complex should individual instructions be?

The x86 family, starting with Intel's 8086 in 1978, took the Complex Instruction Set Computing (CISC) path. x86 instructions vary in length from 1 to 15 bytes. A single instruction might perform a memory load, an arithmetic operation, and a conditional test simultaneously. The philosophy: let the hardware do more work per instruction to reduce the number of instructions the programmer writes.

The RISC (Reduced Instruction Set Computing) movement, led by Patterson and Hennessy at Berkeley and Stanford in the 1980s (the research that won them the 2017 Turing Award), took the opposite view. Fixed-length 32-bit instructions, load/store architecture (only dedicated load/store instructions touch memory, not arithmetic instructions), and a large register file. The philosophy: simple instructions that are easy to decode and pipeline aggressively.

ARM (originally "Acorn RISC Machine," 1985) was the British implementation of RISC that found its killer application in embedded systems and mobile devices - markets where x86's power consumption was prohibitive. Every smartphone for the last 20 years has run on ARM.

The dirty secret of the RISC/CISC debate: modern x86 processors translate CISC instructions into micro-operations (uops) that are essentially RISC operations internally. The "CISC vs RISC" distinction is a historical artifact at the architectural level. What actually differs in 2024 is power delivery design, memory system design, and the degree to which the ISA accommodates modern workloads like ML.

ARM Architecture Deep Dive

ARM's 64-bit architecture (AArch64, introduced with ARMv8 in 2011) provides 31 general-purpose 64-bit registers (x86-64 has 16). More registers reduces register spilling to memory - fewer loads/stores for the same amount of computation.

The instruction encoding is fixed at 32 bits, making instruction fetch and decode much simpler than x86's variable-length encoding. Simpler decode logic consumes less power and less die area - area that can be used for more execution units, larger caches, or lower voltage operation.

ARM Power Efficiency Model

ARM's power efficiency advantage comes from several complementary factors:

Simpler decode: Fixed-length 32-bit instructions decode in a single cycle. x86 decodes a variable-length instruction stream where each instruction must be identified before its length is known - a fundamentally serial process requiring complex hardware.

Less legacy overhead: x86 processors carry decades of backward-compatibility requirements. Every modern Intel Core must be able to run 8086 code from 1978. This adds transistors and complexity that consume power without adding performance for modern workloads.

Lower voltage operation: ARM chips routinely operate at 0.7-0.8V. Many x86 server chips run at 0.9-1.0V. Power scales as PCV2fP \propto CV^2f, so 0.75V vs 0.95V is (0.75/0.95)2=0.62(0.75/0.95)^2 = 0.62 - 38% lower dynamic power at the same frequency and capacitance.

Aggressive big.LITTLE clustering: ARM's heterogeneous CPU design places high-performance "big" cores alongside energy-efficient "little" cores. For background tasks (OS housekeeping, logging, light preprocessing), work shifts to little cores at a fraction of the power. x86 servers run all cores at full power regardless of load.

ARM SVE and SVE2 Vector Extensions

For ML workloads, the critical question is: how wide are the vector units, and what data types do they support?

ARM's Scalable Vector Extension (SVE), introduced with ARMv8.2 (2016, deployed in AWS Graviton3 and Fujitsu A64FX), takes a novel approach. Instead of fixed vector widths (like x86 SSE=128-bit, AVX=256-bit, AVX-512=512-bit), SVE defines vector-length-agnostic (VLA) code. The same binary runs on any SVE implementation, from 128-bit to 2048-bit vectors. The hardware declares its actual vector length at runtime; software queries it and loops accordingly.

Graviton3 implements SVE at 256 bits (4 float32 or 8 float16 per vector register). More importantly, it has two 256-bit SVE execution units per core, giving 8 float32 FMADs (fused multiply-add-doubles) per cycle per core, or 16 float16 per cycle per core.

SVE2 (ARMv9, 2021) adds matrix operations, improved reduction instructions, and better support for the GathervScatter memory access patterns common in sparse neural network inference.

Apple Silicon - The Unified Memory Revolution

Apple Silicon (M1, M2, M3, M4 families) represents a fundamentally different design point from both server ARM (Graviton, Ampere) and x86. The defining architectural decision is unified memory architecture (UMA): the CPU, GPU, and Neural Engine share a single pool of high-bandwidth LPDDR5 or LPDDR5X memory.

On a discrete GPU system (typical ML training server), running inference involves:

  1. Model weights loaded from disk to CPU RAM (PCIe 5.0 bandwidth: 64 GB/s)
  2. Input data prepared in CPU RAM
  3. Data transferred from CPU RAM to GPU VRAM over PCIe (PCIe 4.0 x16: 32 GB/s)
  4. Inference runs on GPU
  5. Results transferred back from GPU VRAM to CPU RAM

Steps 3 and 5 are expensive - PCIe has 32-64 GB/s bandwidth and ~5 microsecond latency. For models small enough that inference latency is measured in microseconds, this PCIe round-trip dominates.

On M2 Ultra (the largest Apple Silicon chip), the unified memory pool is 192GB of LPDDR5X with 800 GB/s memory bandwidth. The GPU, CPU, and Neural Engine all access this pool at peak bandwidth without any PCIe transfer. For a 13B parameter model at float16 (26GB), inferring a single token requires loading ~26GB of weights from memory: at 800 GB/s, that is 33 milliseconds of pure memory bandwidth time. An A100 with 80GB HBM3 at 2 TB/s would take 13ms - 2.5x faster, but costs 15,000vs15,000 vs 2,000 for an M2 Ultra Mac Studio.

For organizations deploying local inference (privacy-sensitive applications, on-device AI), or running many small models concurrently, Apple Silicon is the compelling option.

Neural Engine

The Neural Engine (ANE) is a dedicated ML accelerator present in all Apple Silicon chips. The M2 ANE delivers 15.8 TOPS (trillion operations per second) for INT8 matrix operations. The M3 ANE delivers 18 TOPS. For tasks that fit within ANE's supported operations (primarily convolutions and transformer attention using CoreML-converted models), the ANE provides dramatically better energy efficiency than the GPU: roughly 5-10x lower power for the same throughput.

The limitation: ANE is only accessible via Apple's CoreML framework. PyTorch models require conversion through coremltools with significant restrictions on supported ops. For experimentation and development workflows, the MPS (Metal Performance Shaders) backend for PyTorch provides GPU access without the conversion step, at moderate efficiency.

AWS Graviton3 for ML Inference

Graviton3 (launched 2022) is AWS's third-generation ARM server chip, built on TSMC's 5nm process. Key specifications relevant to ML inference:

  • 64 Neoverse V1 cores at 3.1 GHz
  • SVE at 256-bit vector width, 2 SVE units per core
  • 64 KB L1-D, 1 MB L2 per core, 32 MB shared L3
  • DDR5-4800 memory, 8 channels, ~300 GB/s memory bandwidth
  • TDP: ~150W for the full chip
  • BFLOAT16 support (critical for ML inference at reduced precision)

For ML inference specifically, Graviton3 provides native BFLOAT16 arithmetic - the same reduced-precision format used in training on A100/H100 GPUs. This means inference in BF16 requires no quantization conversion, just loading model weights in the format they were saved in during training.

The Graviton3 instance lineup (c7g family) offers 8, 16, 32, and 48 vCPUs. For inference servers, c7g.8xlarge (32 vCPUs, 64GB RAM) and c7g.16xlarge (64 vCPUs, 128GB RAM) are the typical workhorses.

Ampere Altra - The High-Core-Count ARM Option

While Graviton3 is AWS-proprietary, Ampere Computing's Altra and Altra Max chips are available through OCI, Azure, and hardware resellers. Altra Max provides up to 128 Neoverse N1 cores per chip - more cores than any x86 server chip.

For inference serving architectures that benefit from many lightweight workers (think: thousands of short inference requests per second for small models), Altra's 128-core design means you can run 128 independent model instances simultaneously on a single socket. On x86, the comparable core count is 32-64.

The tradeoff: each Altra core is less powerful than a Graviton3 core or an x86 Xeon core. For workloads that need one fast inference per request (latency-sensitive), Graviton3's stronger per-core performance wins. For workloads that need many concurrent inferences (throughput-optimized), Altra's core count wins.

Platform Detection and Adaptive Code

"""
Platform detection for adaptive ML inference deployment.
Detects CPU architecture, available instruction sets, and optimal runtime.
"""
import platform
import subprocess
import sys
from typing import Dict, Optional


def detect_cpu_architecture() -> Dict:
"""
Detect CPU architecture and capabilities for adaptive runtime selection.

Returns a dict with architecture info and optimization recommendations.
"""
machine = platform.machine().lower()
system = platform.system()

info = {
"architecture": machine,
"system": system,
"is_arm": machine in ("aarch64", "arm64", "armv7l"),
"is_x86": machine in ("x86_64", "amd64", "i386", "i686"),
"is_apple_silicon": False,
"is_graviton": False,
"cpu_brand": "",
"vector_width_bits": 128, # default assumption
"has_avx512": False,
"has_avx2": False,
"has_sve": False,
"has_bf16": False,
"has_mps": False, # Apple Metal Performance Shaders
}

# Read CPU brand string
try:
if system == "Linux":
with open("/proc/cpuinfo") as f:
for line in f:
if "model name" in line:
info["cpu_brand"] = line.split(":")[1].strip()
break
elif system == "Darwin":
result = subprocess.run(
["sysctl", "-n", "machdep.cpu.brand_string"],
capture_output=True, text=True
)
info["cpu_brand"] = result.stdout.strip()
except Exception:
pass

brand_lower = info["cpu_brand"].lower()

# Detect Apple Silicon
if system == "Darwin" and machine in ("arm64", "aarch64"):
info["is_apple_silicon"] = True
info["vector_width_bits"] = 128 # NEON
info["has_bf16"] = True # M2+ has BF16

# Check if MPS is available
try:
import torch
info["has_mps"] = torch.backends.mps.is_available()
except ImportError:
pass

# Detect AWS Graviton
elif "neoverse" in brand_lower or "graviton" in brand_lower:
info["is_graviton"] = True
info["vector_width_bits"] = 256 # SVE 256-bit on Graviton3
info["has_sve"] = True
info["has_bf16"] = "v3" in brand_lower or "graviton3" in brand_lower

# Detect x86 capabilities via CPUID
elif info["is_x86"]:
try:
import cpuinfo # pip install py-cpuinfo
cpu = cpuinfo.get_cpu_info()
flags = cpu.get("flags", [])
info["has_avx2"] = "avx2" in flags
info["has_avx512"] = "avx512f" in flags
if info["has_avx512"]:
info["vector_width_bits"] = 512
info["has_bf16"] = "avx512_bf16" in flags
elif info["has_avx2"]:
info["vector_width_bits"] = 256
except ImportError:
# Fallback: check /proc/cpuinfo flags
try:
with open("/proc/cpuinfo") as f:
content = f.read()
info["has_avx2"] = " avx2 " in content
info["has_avx512"] = " avx512f " in content
except Exception:
pass

return info


def select_optimal_pytorch_device(arch_info: Optional[Dict] = None) -> str:
"""
Select the optimal PyTorch compute device based on platform capabilities.

Priority: CUDA GPU > Apple MPS > CPU (with architecture-specific tuning)
"""
if arch_info is None:
arch_info = detect_cpu_architecture()

try:
import torch

# GPU is always preferred for training and large-batch inference
if torch.cuda.is_available():
gpu_name = torch.cuda.get_device_name(0)
print(f"Using CUDA GPU: {gpu_name}")
return "cuda"

# Apple Silicon MPS for medium-sized model inference
if arch_info["has_mps"] and torch.backends.mps.is_available():
print("Using Apple MPS (Metal Performance Shaders)")
return "mps"

# CPU with architecture-specific optimizations
if arch_info["is_graviton"]:
print(f"Using CPU: AWS Graviton3 (SVE 256-bit, BF16={'yes' if arch_info['has_bf16'] else 'no'})")
elif arch_info["is_x86"] and arch_info["has_avx512"]:
print(f"Using CPU: x86 with AVX-512 (512-bit vectors, BF16={'yes' if arch_info['has_bf16'] else 'no'})")
else:
print(f"Using CPU: {arch_info['cpu_brand']} ({arch_info['vector_width_bits']}-bit vectors)")

return "cpu"

except ImportError:
return "cpu"


def configure_pytorch_for_arm(arch_info: Optional[Dict] = None):
"""
Configure PyTorch runtime optimizations for ARM platforms.
Called once at application startup.
"""
if arch_info is None:
arch_info = detect_cpu_architecture()

if not arch_info["is_arm"]:
return

try:
import torch

# Enable BF16 inference if supported (Graviton3, M2+)
if arch_info["has_bf16"]:
torch.set_default_dtype(torch.bfloat16)
print("ARM: Enabled BF16 by default (hardware-accelerated)")

# Configure thread pool for ARM topology
# On Graviton3: 64 vCPUs, use all of them for inference
n_cores = _get_physical_core_count()
torch.set_num_threads(n_cores)
torch.set_num_interop_threads(max(1, n_cores // 4))
print(f"ARM: Set PyTorch threads to {n_cores} (physical cores)")

# Enable oneDNN graph optimization (works on ARM with ACL backend)
torch.jit.enable_onednn_fusion(True)
print("ARM: Enabled oneDNN JIT fusion")

except Exception as e:
print(f"ARM optimization setup warning: {e}")


def _get_physical_core_count() -> int:
"""Get physical (not logical/hyperthreaded) core count."""
try:
result = subprocess.run(
["lscpu", "--json"], capture_output=True, text=True
)
import json
data = json.loads(result.stdout)
for entry in data.get("lscpu", []):
if entry.get("field") == "Core(s) per socket:":
n_sockets_r = [e for e in data["lscpu"] if e.get("field") == "Socket(s):"]
n_sockets = int(n_sockets_r[0]["data"]) if n_sockets_r else 1
return int(entry["data"]) * n_sockets
except Exception:
pass
import os
return os.cpu_count() or 4

PyTorch Inference on ARM - Practical Configuration

"""
ARM-optimized PyTorch inference for production serving.
Covers: model loading, quantization, batching, and thread pool tuning.
"""
import torch
import torch.nn as nn
from typing import Optional
import time
import os


def load_model_for_arm_inference(
model_path: str,
use_bf16: bool = True,
use_torch_compile: bool = True,
) -> nn.Module:
"""
Load and optimize a PyTorch model for ARM CPU inference.

Key optimizations:
1. BF16 precision - 2x throughput vs FP32 on Graviton3 SVE
2. torch.compile with 'inductor' backend - auto-vectorization for SVE
3. torch.jit.freeze - eliminates Python overhead in the inference loop
"""
arch_info = detect_cpu_architecture()

# Load model
model = torch.load(model_path, map_location="cpu")
model.eval()

# Convert to BF16 for ARM inference (hardware-accelerated on Graviton3/M2+)
if use_bf16 and arch_info.get("has_bf16"):
model = model.to(torch.bfloat16)
print("Converted model to BF16 for ARM")

# Disable gradient computation (saves memory and compute)
for param in model.parameters():
param.requires_grad_(False)

# torch.compile generates optimized machine code via LLVM/TVM backend
# 'inductor' backend auto-vectorizes for the detected SIMD width
if use_torch_compile:
try:
model = torch.compile(
model,
backend="inductor",
options={
"triton.cudagraphs": False, # CPU-only
"cpp.threads": os.cpu_count(),
}
)
print("Applied torch.compile (inductor backend)")
except Exception as e:
print(f"torch.compile not available: {e}")

return model


def benchmark_inference_arm_vs_x86(
model: nn.Module,
batch_sizes: list = [1, 4, 8, 16, 32],
seq_len: int = 512,
hidden_dim: int = 768,
n_warmup: int = 10,
n_iterations: int = 100,
) -> dict:
"""
Benchmark model inference throughput and latency on current hardware.
Run on both ARM and x86 instances and compare results.
"""
arch_info = detect_cpu_architecture()
results = {
"platform": arch_info["cpu_brand"],
"architecture": arch_info["architecture"],
"vector_width_bits": arch_info["vector_width_bits"],
"benchmarks": {}
}

for batch_size in batch_sizes:
# Generate random input
x = torch.randn(batch_size, seq_len, hidden_dim)
if arch_info.get("has_bf16"):
x = x.to(torch.bfloat16)

# Warmup - important for ARM where JIT compilation happens on first call
with torch.no_grad():
for _ in range(n_warmup):
_ = model(x)

# Benchmark
latencies = []
with torch.no_grad():
for _ in range(n_iterations):
t0 = time.perf_counter()
_ = model(x)
latencies.append((time.perf_counter() - t0) * 1000) # ms

latencies.sort()
results["benchmarks"][batch_size] = {
"p50_ms": latencies[len(latencies) // 2],
"p95_ms": latencies[int(len(latencies) * 0.95)],
"p99_ms": latencies[int(len(latencies) * 0.99)],
"throughput_samples_per_sec": batch_size / (latencies[len(latencies) // 2] / 1000),
}

print(f"Batch {batch_size:3d}: "
f"p50={results['benchmarks'][batch_size]['p50_ms']:.1f}ms, "
f"p99={results['benchmarks'][batch_size]['p99_ms']:.1f}ms, "
f"throughput={results['benchmarks'][batch_size]['throughput_samples_per_sec']:.0f} samples/s")

return results


def performance_per_watt_comparison():
"""
Reference comparison of ARM vs x86 performance per watt for inference.
Numbers from public benchmarks (MLPerf, cloud provider benchmarks, 2023-2024).
"""
comparison = {
"bert_large_inference_fp32": {
"Intel Xeon Platinum 8375C (c6i.16xlarge)": {
"throughput_samples_sec": 120,
"tdp_watts": 270,
"perf_per_watt": 0.44,
"vcpus": 64,
"hourly_cost_usd": 2.72,
"cost_per_1m_samples": 6.3,
},
"AWS Graviton3 (c7g.16xlarge)": {
"throughput_samples_sec": 165,
"tdp_watts": 150,
"perf_per_watt": 1.10,
"vcpus": 64,
"hourly_cost_usd": 2.09,
"cost_per_1m_samples": 3.5,
},
"Apple M2 Ultra (Mac Studio)": {
"throughput_samples_sec": 95,
"tdp_watts": 60,
"perf_per_watt": 1.58,
"vcpus": 24, # performance cores
"hourly_cost_usd": None, # capital cost model
"cost_per_1m_samples": None,
},
},
}

print("\nPerformance Per Watt Comparison - BERT-Large Inference:")
print("-" * 75)
for instance, data in comparison["bert_large_inference_fp32"].items():
print(f"\n{instance}:")
print(f" Throughput: {data['throughput_samples_sec']:>6.0f} samples/s")
print(f" TDP: {data['tdp_watts']:>6.0f} W")
print(f" Perf/Watt: {data['perf_per_watt']:>6.2f} samples/s/W")
if data["cost_per_1m_samples"]:
print(f" Cost/1M samples: ${data['cost_per_1m_samples']:>5.1f}")

return comparison

Docker Multi-Architecture Builds

Deploying on both ARM and x86 requires multi-arch Docker images:

# Dockerfile - supports both linux/amd64 and linux/arm64
FROM python:3.11-slim

# The base image is multi-arch; Python and pip work identically on both
ARG TARGETPLATFORM
ARG BUILDPLATFORM

RUN echo "Building for $TARGETPLATFORM on $BUILDPLATFORM"

# Install architecture-specific optimized libraries
RUN apt-get update && apt-get install -y \
libopenblas-dev \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .

# pip install is architecture-aware: installs ARM wheels on ARM, x86 wheels on x86
RUN pip install --no-cache-dir -r requirements.txt

# Install ARM-specific optimizations when building for ARM
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
pip install --no-cache-dir \
torch --index-url https://download.pytorch.org/whl/cpu && \
echo "ARM: installed ARM-optimized PyTorch"; \
elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
pip install --no-cache-dir \
torch --index-url https://download.pytorch.org/whl/cpu && \
echo "x86: installed x86-optimized PyTorch"; \
fi

COPY . .
CMD ["python", "serve.py"]
# Build and push multi-arch image using buildx

# One-time setup: create a multi-arch builder
docker buildx create --name multiarch --driver docker-container --use
docker buildx inspect --bootstrap

# Build for both architectures simultaneously and push to registry
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag your-registry.com/ml-inference:v1.2.3 \
--push \
.

# Verify the manifest includes both architectures
docker manifest inspect your-registry.com/ml-inference:v1.2.3

# Pull and run: Docker automatically selects the right architecture
docker pull your-registry.com/ml-inference:v1.2.3
docker run --rm your-registry.com/ml-inference:v1.2.3 python -c \
"import torch; print('PyTorch', torch.__version__, '| Device:', torch.device('cpu'))"

# For Kubernetes: the pod spec is identical for ARM and x86 nodes
# Kubernetes pulls the correct arch layer from the manifest automatically

Cross-Compilation for ARM

When developing on x86 machines but deploying to ARM, cross-compilation is necessary for native extensions:

# CMakeLists.txt - cross-compiles C++ extension for ARM
cmake_minimum_required(VERSION 3.20)
project(ml_extension LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)

# Detect if cross-compiling
if(CMAKE_CROSSCOMPILING)
message(STATUS "Cross-compiling for: ${CMAKE_SYSTEM_PROCESSOR}")
endif()

# Architecture-specific optimizations
if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64")
# ARM64: enable SVE and NEON
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -march=armv8.2-a+sve+fp16+bf16")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -ffast-math")
message(STATUS "ARM64: Enabling SVE+BF16 optimizations")
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64")
# x86-64: enable AVX-512 (check runtime support separately)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -march=skylake-avx512")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -ffast-math")
message(STATUS "x86-64: Enabling AVX-512 optimizations")
endif()

# Source files
add_library(ml_extension SHARED
src/attention_kernel.cpp
src/layer_norm.cpp
src/embedding_lookup.cpp
)

target_include_directories(ml_extension PUBLIC include/)

# Link against architecture-optimized BLAS
if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64")
# ARM Compute Library (Arm's optimized BLAS for Neoverse/Cortex)
find_library(ACL_LIB arm_compute PATHS /opt/arm/acl/lib)
if(ACL_LIB)
target_link_libraries(ml_extension ${ACL_LIB})
target_compile_definitions(ml_extension PRIVATE USE_ACL=1)
message(STATUS "ARM: Using Arm Compute Library")
else()
target_link_libraries(ml_extension openblas)
message(STATUS "ARM: Falling back to OpenBLAS")
endif()
else()
# x86: use Intel MKL or OpenBLAS
find_package(MKL QUIET)
if(MKL_FOUND)
target_link_libraries(ml_extension MKL::MKL)
message(STATUS "x86: Using Intel MKL")
else()
target_link_libraries(ml_extension openblas)
message(STATUS "x86: Falling back to OpenBLAS")
endif()
endif()
# Cross-compile from x86 Linux to ARM64
# Requires: apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu

cmake -B build-arm64 \
-DCMAKE_TOOLCHAIN_FILE=cmake/aarch64-toolchain.cmake \
-DCMAKE_BUILD_TYPE=Release

cmake --build build-arm64 -j$(nproc)

# Verify the output binary targets ARM64
file build-arm64/libml_extension.so
# Output: ELF 64-bit LSB shared object, ARM aarch64

# aarch64-toolchain.cmake content:
cat > cmake/aarch64-toolchain.cmake << 'EOF'
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
set(CMAKE_FIND_ROOT_PATH /usr/aarch64-linux-gnu)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
EOF

Total Cost of Ownership Analysis

"""
TCO analysis comparing ARM and x86 for ML inference workloads.
Based on AWS on-demand pricing and public MLPerf benchmarks.
"""
from dataclasses import dataclass
from typing import Dict


@dataclass
class InferenceHardware:
name: str
architecture: str
vcpus: int
ram_gb: int
hourly_cost_usd: float
throughput_samples_per_sec: float # BERT-Large batch=1
p99_latency_ms: float
tdp_watts: float


HARDWARE_OPTIONS = {
"c6i_4xlarge_x86": InferenceHardware(
name="c6i.4xlarge (Intel Xeon, x86)",
architecture="x86",
vcpus=16, ram_gb=32,
hourly_cost_usd=0.68,
throughput_samples_per_sec=30,
p99_latency_ms=35,
tdp_watts=70,
),
"c7g_4xlarge_arm": InferenceHardware(
name="c7g.4xlarge (Graviton3, ARM)",
architecture="ARM",
vcpus=16, ram_gb=32,
hourly_cost_usd=0.58,
throughput_samples_per_sec=42,
p99_latency_ms=26,
tdp_watts=38,
),
"c6i_16xlarge_x86": InferenceHardware(
name="c6i.16xlarge (Intel Xeon, x86)",
architecture="x86",
vcpus=64, ram_gb=128,
hourly_cost_usd=2.72,
throughput_samples_per_sec=120,
p99_latency_ms=31,
tdp_watts=270,
),
"c7g_16xlarge_arm": InferenceHardware(
name="c7g.16xlarge (Graviton3, ARM)",
architecture="ARM",
vcpus=64, ram_gb=128,
hourly_cost_usd=2.09,
throughput_samples_per_sec=165,
p99_latency_ms=23,
tdp_watts=150,
),
}


def tco_analysis(
target_throughput_per_sec: float = 10_000,
hours_per_day: float = 24,
days_per_year: float = 365,
) -> Dict:
"""
Compute TCO for each hardware option to meet a throughput target.
Returns: instances needed, annual cost, performance comparison.
"""
results = {}
print(f"\nTCO Analysis: {target_throughput_per_sec:,} samples/sec target")
print(f"Operating: {hours_per_day * days_per_year:,.0f} hours/year")
print("-" * 70)

for key, hw in HARDWARE_OPTIONS.items():
instances_needed = -(-target_throughput_per_sec // hw.throughput_samples_per_sec) # ceil
annual_cost = instances_needed * hw.hourly_cost_usd * hours_per_day * days_per_year
total_power_kw = instances_needed * hw.tdp_watts / 1000
cost_per_1m = annual_cost / (target_throughput_per_sec * 3600 * hours_per_day * days_per_year / 1_000_000)

results[key] = {
"instances": instances_needed,
"annual_usd": annual_cost,
"power_kw": total_power_kw,
"p99_latency": hw.p99_latency_ms,
"cost_per_1m": cost_per_1m,
}

print(f"\n{hw.name}:")
print(f" Instances needed: {instances_needed:>6,}")
print(f" Annual cost: ${annual_cost:>10,.0f}")
print(f" Total power: {total_power_kw:>8.1f} kW")
print(f" P99 latency: {hw.p99_latency_ms:>8.0f} ms")
print(f" Cost per 1M reqs: ${cost_per_1m:>8.4f}")

# Compare ARM vs x86 for same size
for size in ["4xlarge", "16xlarge"]:
x86_key = f"c6i_{size}_x86"
arm_key = f"c7g_{size}_arm"
if x86_key in results and arm_key in results:
savings_pct = (1 - results[arm_key]["annual_usd"] / results[x86_key]["annual_usd"]) * 100
latency_improvement = (1 - results[arm_key]["p99_latency"] / results[x86_key]["p99_latency"]) * 100
print(f"\n{size}: ARM vs x86 - "
f"Cost savings: {savings_pct:.0f}%, "
f"Latency improvement: {latency_improvement:.0f}%")

return results

When to Use Each Architecture

Production Engineering Notes

Thread Tuning for ARM Inference Servers

ARM server chips like Graviton3 do not use hyperthreading - each vCPU is a full physical core. This means thread count recommendations differ from x86:

import os
import torch

def configure_threads_for_arm():
"""
Graviton3: 64 vCPUs = 64 physical cores (no HT).
Set PyTorch to use all physical cores for inference.

On x86 with HT: use physical_cores = total_vcpus / 2
On ARM without HT: use all vcpus
"""
is_arm = platform.machine().lower() in ("aarch64", "arm64")
n_vcpus = os.cpu_count()

if is_arm:
# All vCPUs are physical cores on ARM
inference_threads = n_vcpus
else:
# On x86 with HT, using all logical cores for inference is often worse
# because SIMD units are shared between HT siblings
inference_threads = n_vcpus // 2

torch.set_num_threads(inference_threads)
# Fewer inter-op threads to avoid contention
torch.set_num_interop_threads(max(2, inference_threads // 8))

print(f"Configured: {inference_threads} intra-op threads, "
f"{max(2, inference_threads // 8)} inter-op threads")

NUMA Topology on ARM Servers

Multi-socket ARM servers (Graviton3, Ampere Altra) have NUMA topology similar to x86. On a dual-socket Altra Max system with 128 cores per socket, cross-socket memory access adds 2-3x latency. Use numactl to pin inference workers to their local NUMA node:

# Pin process to NUMA node 0 (cores 0-127 on Altra Max)
numactl --cpunodebind=0 --membind=0 python inference_server.py

# Check NUMA topology on ARM
numactl --hardware

:::warning ARM PyTorch Builds May Not Be Fully Optimized Out of the Box The default PyTorch wheel from pip install torch on ARM may not enable all platform optimizations. For production Graviton3 deployments, check whether your torch build includes ACL (Arm Compute Library) support: torch.backends.xnnpack.enabled. Consider using AWS's Graviton-optimized Python packages (pip install aws-graviton-pytorch) which are pre-configured for maximum Graviton3 performance. :::

:::danger x86-Specific Assembly or Intrinsics Break ARM Builds Silently Libraries that use __m256 or __m512 Intel intrinsics (AVX2/AVX-512) will compile but produce incorrect results or crashes on ARM, not a clean compiler error. Always check your transitive dependencies for x86-specific optimized paths. FAISS, for example, has separate x86 and ARM build paths - the ARM build must be selected explicitly at compile time with -DFAISS_OPT_LEVEL=generic initially, then upgraded to the appropriate ARM SIMD level. :::

Interview Questions and Answers

Q1: What is the fundamental architectural difference between RISC (ARM) and CISC (x86) ISAs, and why does it matter for power efficiency?

RISC uses fixed-length instructions (ARM: always 32 bits), a load/store architecture (only dedicated instructions access memory), and a large register file (ARM64: 31 registers). CISC instructions are variable-length (x86: 1-15 bytes), can encode complex operations like memory-to-memory moves, and have fewer registers (x86-64: 16 general purpose).

For power efficiency, RISC's fixed-length encoding dramatically simplifies the instruction decode unit. x86 instruction decode must first identify where each instruction ends before it can decode it - this is a fundamentally serial, complex process requiring significant transistor budget and power. ARM decode is parallelizable: all instructions are the same size, so the decoder knows exactly where each instruction starts.

Additionally, ARM's ISA does not carry 46 years of backward compatibility requirements. Every x86 processor must be able to run 16-bit 8086 code from 1978. The logic to support this exists in every Intel Core, consuming power on every chip even though no modern software uses it. This is "dark silicon overhead" - transistors that exist but cannot be powered down completely.

The practical result: Graviton3 (ARM) has a TDP of ~150W for 64 cores. A comparable Intel Xeon Platinum is 205-270W. For the same computational output on inference workloads, ARM consumes 35-45% less power.

Q2: Explain Apple Silicon's unified memory architecture and why it matters for ML model inference.

On a standard PC or server with a discrete GPU, CPU and GPU have separate memory pools. The CPU has DDR5 DRAM (up to 300 GB/s bandwidth). The GPU has HBM or GDDR VRAM (up to 2 TB/s for A100). Data moves between them over PCIe (32-64 GB/s bandwidth, 5 microsecond latency). For every inference request, model weights must be resident in GPU VRAM; if the model is larger than VRAM, you cannot run it on that GPU at all.

Apple Silicon eliminates this boundary. The CPU, GPU, and Neural Engine all access the same physical DRAM pool, connected to all compute units at the full memory bandwidth (up to 800 GB/s on M2 Ultra). There is no PCIe transfer. Model weights loaded once are immediately accessible to both CPU and GPU inference paths.

This matters for several scenarios: (1) Models that fit in RAM but not in VRAM - a 32GB GPU cannot run a 13B float16 model (26GB), but an M2 with 32GB unified memory can run it with 6GB to spare. (2) Inference latency for batch size 1 - no PCIe transfer eliminates 2-5ms of constant overhead. (3) Power efficiency at small batch sizes - A100 requires 300-400W to run; M2 Max with similar effective throughput for 7B-13B models uses 60-100W.

Q3: Comparing Graviton3 and Intel Xeon for a BERT-Large inference serving workload, walk through how you would quantify the expected cost difference.

Start by measuring both platforms with a realistic workload benchmark. Use a fixed BERT-Large model, fixed input sequence length (128 or 512 tokens), and batch size 1 (latency-optimized) or batch size 16 (throughput-optimized). Run 1000 warmup iterations, then 10,000 timed iterations.

From public benchmarks and AWS performance numbers: Graviton3 (c7g.16xlarge, 64 vCPUs, 2.09/hour)achievesapproximately165BERTLargesamples/second.IntelXeonPlatinum(c6i.16xlarge,64vCPUs,2.09/hour) achieves approximately 165 BERT-Large samples/second. Intel Xeon Platinum (c6i.16xlarge, 64 vCPUs, 2.72/hour) achieves approximately 120 BERT-Large samples/second.

For a workload needing 10,000 samples/second continuously: you need 61 Graviton3 instances (127/hour,127/hour, 1.11M/year) or 84 Intel instances (228/hour,228/hour, 2.0M/year). The annual savings are ~$900,000 for this single model.

The savings come from two sources: Graviton3 is ~38% cheaper per instance AND delivers ~38% more throughput per instance, for a combined ~55% reduction in total infrastructure cost for the same throughput target.

Q4: What is ARM SVE and how does it differ from Intel AVX-512?

Both SVE (Scalable Vector Extension) and AVX-512 provide wide SIMD operations for ML workloads, but their design philosophies differ fundamentally.

AVX-512 is a fixed-width extension: all AVX-512 operations work on 512-bit registers containing 16 float32 or 32 float16 values. The instruction set is explicitly written for 512-bit width. Code that uses __m512 intrinsics or AVX-512 compiler flags only runs on processors that implement exactly 512-bit AVX-512.

SVE is vector-length-agnostic (VLA). The hardware declares its actual vector width (128 to 2048 bits) at runtime through a special register. Software uses a predicate register to control which lanes are active, allowing a single binary to efficiently use the available vector width on any SVE processor. Graviton3 implements 256-bit SVE; future chips might implement 512 or 1024-bit SVE while running the same binary at higher throughput.

The practical implication: AVX-512 code compiled for Skylake Xeon will fail to run on a Cascade Lake Xeon that lacks certain AVX-512 sub-extensions (e.g., AVX512_VNNI). SVE code compiled for "generic SVE" runs correctly on any SVE processor, adapting to the available width automatically. This makes SVE binaries more portable while still extracting maximum hardware performance.

Q5: When would you NOT use ARM for an ML workload and insist on x86?

Three clear cases where x86 is the right choice:

First, GPU training. NVIDIA's GPU ecosystem - CUDA, cuDNN, cuBLAS, NVLink - is tightly integrated with x86 servers. AWS Trainium is the only ARM-based ML accelerator with significant ecosystem support, and it is purpose-built for specific model types. Training LLMs, diffusion models, or any large-scale gradient-based optimization is done on x86 + NVIDIA GPU without exception in production today.

Second, raw single-thread peak compute for latency-critical tasks. Per-core, a high-clocked Intel Core or AMD EPYC core with AVX-512 delivers more raw FLOPS than a Graviton3 core (which uses 256-bit SVE). For services requiring sub-5ms P99 latency with batch size 1 and complex model architectures, x86's higher per-core throughput wins. The break-even is workload-dependent.

Third, legacy code with x86-specific optimizations. Some ML library components use hand-written x86 assembly or compiler intrinsics (FAISS GPU, some TensorRT components, Intel oneAPI libraries). Porting these to ARM requires either substituting alternative libraries or rewriting the optimized paths. If your organization has significant investment in x86-optimized custom kernels, the migration cost may exceed the TCO savings for years.

© 2026 EngineersOfAI. All rights reserved.