Building ARM64 Python Wheels

Building aarch64 wheels on an x64 GitHub Actions runner puts every compile under QEMU. Move the aarch64 leg of the cibuildwheel matrix to a native runner.

Last verified:

To build ARM64 Python wheels in GitHub Actions, run the aarch64 leg of your cibuildwheel matrix on a native ARM64 runner such as warp-ubuntu-latest-arm64-8x at $0.012 per minute and delete the docker/setup-qemu-action step that the x64 route depends on. Every compile, link, and test inside the manylinux_2_28_aarch64 container then executes on aarch64 hardware instead of being translated instruction by instruction.

This guide covers where an emulated wheel build spends its time, the build matrix that maps Python versions and platform tags to runner labels, the workflow diff and the full release workflow with artifact upload, and a time and cost model for a package with a stated number of C extension modules. The rest of a Python pipeline, including dependency caching and test sharding, is covered on the Python on GitHub Actions page.

Diagnosis

Where the aarch64 leg spends its time

On Linux, cibuildwheel does not build wheels on the runner itself. It pulls a manylinux or musllinux container image, one per platform tag, and runs the build inside it so the resulting wheel links against an old enough glibc or musl to install on the range of distributions the tag promises.

The architecture of that container follows the host. On an x86_64 runner, an aarch64 container image runs only because docker/setup-qemu-action wrote entries into /proc/sys/fs/binfmt_misc telling the kernel to hand any aarch64 binary to qemu-aarch64-static. From that point the container is full of aarch64 binaries, and every one of them runs as translated code with each syscall marshalled between the two ABIs. That is the whole source of the gap between the two legs of the matrix, and it is the same mechanism described in more depth for image builds in moving ARM64 Docker builds off QEMU.

The exposure is uneven across the stages of a wheel build, which is why two packages on the same runner size see very different emulated durations.

Stage of the wheel buildRuns whereEmulation exposure
Pull and unpack manylinux_2_28_aarch64Host Docker daemonNone. Bytes move at host speed.
pip download of build requirementsNetwork bound inside the containerLow. The wait dominates.
setuptools or maturin driver, pure Python bookkeepingInterpreted inside the containerModerate. The interpreter is itself translated.
C, C++, Cython, and Rust compile stepsInside the containerHighest. CPU bound from start to finish.
Link and strip stepsInside the containerHigh, and syscall heavy on top of the translation.
auditwheel repair, which rewrites ELF headers with patchelfInside the containerModerate, and syscall heavy.
CIBW_TEST_COMMAND test runInside the containerHigh for anything CPU bound.
actions/upload-artifact of the wheelhouseHostNone.

A package that is pure Python with a thin ctypes shim barely notices the difference. A package that compiles three extension modules against NumPy headers for five interpreter versions spends nearly all of its billed minutes inside the interpreter.

Confirming it in one run

Add a temporary step ahead of the cibuildwheel step and read three lines.

      - name: report architecture
        run: |
          uname -m
          python -c "import sysconfig; print(sysconfig.get_platform())"
          ls /proc/sys/fs/binfmt_misc/ | grep -i qemu || echo "no qemu handlers"

On the emulated job uname -m prints x86_64, sysconfig.get_platform() prints linux-x86_64, and the binfmt listing shows a qemu-aarch64 entry. On a native ARM64 runner uname -m prints aarch64, the platform string is linux-aarch64, and no handler is involved.

Then measure. cibuildwheel closes each run by printing the wheel count and the elapsed time, so both numbers you need for the model below come out of the log without extra instrumentation. Record the emulated aarch64 leg and the x86_64 leg from the same commit, then repeat the aarch64 leg on a native runner.

Why a bigger x64 runner does not close the gap

Resizing the x64 runner leaves the translation in place, so the per-instruction cost is unchanged. Extra vCPU also does less than it looks on a wheel build, because cibuildwheel builds one wheel at a time within a job. The only parallelism a larger machine can use is whatever the package's own build system exposes, such as make -j in a C extension or cargo compiling crates in parallel, and both of those are running under the interpreter too.

Two more constraints worth checking before you plan the fix. Wheels are tagged, so a manylinux_2_28_aarch64 artifact and a musllinux_1_2_aarch64 artifact are separate builds with separate durations rather than one build with two outputs. And a GitHub Actions job runs for at most 6 hours, which an emulated matrix covering five interpreter versions can approach once a build requirement such as NumPy or PyArrow starts compiling from source.

Fix

Move the aarch64 rows of the matrix onto a native ARM64 runner label and drop the QEMU step. The Dockerfile-free parts of the release workflow, including artifact upload and publishing, stay exactly as they are.

The wheel build matrix

This is the mapping to hold in one place: interpreter version, cibuildwheel build identifier, the tag that ends up in the wheel filename, and the runner label the row runs on.

Pythoncibuildwheel build identifierWheel platform tagRunner label
3.9cp39-manylinux_aarch64cp39-cp39-manylinux_2_28_aarch64warp-ubuntu-latest-arm64-8x
3.10cp310-manylinux_aarch64cp310-cp310-manylinux_2_28_aarch64warp-ubuntu-latest-arm64-8x
3.11cp311-manylinux_aarch64cp311-cp311-manylinux_2_28_aarch64warp-ubuntu-latest-arm64-8x
3.12cp312-manylinux_aarch64cp312-cp312-manylinux_2_28_aarch64warp-ubuntu-latest-arm64-8x
3.13cp313-manylinux_aarch64cp313-cp313-manylinux_2_28_aarch64warp-ubuntu-latest-arm64-8x
3.12 on muslcp312-musllinux_aarch64cp312-cp312-musllinux_1_2_aarch64warp-ubuntu-latest-arm64-8x
3.13 on muslcp313-musllinux_aarch64cp313-cp313-musllinux_1_2_aarch64warp-ubuntu-latest-arm64-8x
3.9 to 3.13 on x64cp3{9,10,11,12,13}-manylinux_x86_64manylinux_2_28_x86_64warp-ubuntu-latest-x64-8x

The manylinux_2_28 part of the tag is the glibc floor set by PEP 600 and is chosen by the image you point cibuildwheel at, so it is independent of the runner. The aarch64 part is what the native runner change is about. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so the x86_64 rows and the aarch64 rows of one matrix both run on native hardware under the same workflow.

Runner labels and rates

Linux ARM64 labels, shapes, and per-minute rates come from the cloud runners documentation and the pricing page.

LabelvCPURAMStorageRate per minutePinned alias
warp-ubuntu-latest-arm64-2x28GB150GB SSD$0.003warp-ubuntu-2404-arm64-2x
warp-ubuntu-latest-arm64-4x416GB150GB SSD$0.006warp-ubuntu-2404-arm64-4x
warp-ubuntu-latest-arm64-8x832GB150GB SSD$0.012warp-ubuntu-2404-arm64-8x
warp-ubuntu-latest-arm64-16x1664GB150GB SSD$0.024warp-ubuntu-2404-arm64-16x
warp-ubuntu-latest-arm64-32x32128GB150GB SSD$0.048warp-ubuntu-2404-arm64-32x

Ubuntu 26.04 ships under warp-ubuntu-2604-arm64-8x and the rest of that series at the same rates. Pin the alias in runs-on when you want the image version to move on your schedule rather than when latest moves. The full label list is on the Linux ARM64 runner page.

Two porting details

The Ubuntu 24.04 ARM64 image is compatible with the GitHub ARM64 image and carries the same preinstalled tooling, per the preinstalled software reference. Its work dir is /runner/_work, which differs from GitHub's /home/runner/work/, so a release script with a hardcoded runner path should read $GITHUB_WORKSPACE instead. The older Ubuntu 22.04 ARM64 image is the exception: it is based on the upstream ubuntu:22.04 image, defaults to the root user, and carries minimal tooling, so a job pinned there installs its own build dependencies with apt first.

If a wheel builds on the emulated leg and fails on the native one, the cause is usually a build requirement resolved from a source distribution with an architecture assumption baked in, or a step downloading a fixed x86_64 URL.

Configuration

Here is the workflow most projects start from. One job, an x64 runner, QEMU registering handlers, and cibuildwheel asked for both architectures.

name: wheels
on:
  push:
    tags: ["v*"]

jobs:
  wheels:
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-qemu-action@v3
        with:
          platforms: arm64

      - uses: pypa/[email protected]
        env:
          CIBW_ARCHS_LINUX: x86_64 aarch64

      - uses: actions/upload-artifact@v4
        with:
          name: wheels
          path: ./wheelhouse/*.whl

The native matrix

Split the single job into one row per architecture and libc family, give each row its own runner label, and remove the QEMU step. Each row uploads its own artifact so the rows stay independent.

name: wheels
on:
  push:
    tags: ["v*"]
  workflow_dispatch:

jobs:
  wheels:
    name: ${{ matrix.tag }}
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 60
    strategy:
      fail-fast: false
      matrix:
        include:
          - tag: manylinux-x86_64
            runner: warp-ubuntu-latest-x64-8x
            archs: x86_64
            build: "cp39-manylinux_x86_64 cp310-manylinux_x86_64 cp311-manylinux_x86_64 cp312-manylinux_x86_64 cp313-manylinux_x86_64"
          - tag: manylinux-aarch64
            runner: warp-ubuntu-latest-arm64-8x
            archs: aarch64
            build: "cp39-manylinux_aarch64 cp310-manylinux_aarch64 cp311-manylinux_aarch64 cp312-manylinux_aarch64 cp313-manylinux_aarch64"
          - tag: musllinux-aarch64
            runner: warp-ubuntu-latest-arm64-8x
            archs: aarch64
            build: "cp312-musllinux_aarch64 cp313-musllinux_aarch64"
    steps:
      - uses: actions/checkout@v4

      - uses: pypa/[email protected]
        env:
          CIBW_ARCHS_LINUX: ${{ matrix.archs }}
          CIBW_BUILD: ${{ matrix.build }}
          CIBW_MANYLINUX_AARCH64_IMAGE: manylinux_2_28
          CIBW_BUILD_VERBOSITY: 1
          CIBW_TEST_COMMAND: python -c "import mypkg; mypkg.selftest()"

      - uses: actions/upload-artifact@v4
        with:
          name: wheels-${{ matrix.tag }}
          path: ./wheelhouse/*.whl
          if-no-files-found: error

The edit against the starting workflow is three lines of removal and one of addition per row.

-    runs-on: ubuntu-latest
+    runs-on: warp-ubuntu-latest-arm64-8x
     steps:
       - uses: actions/checkout@v4

-      - uses: docker/setup-qemu-action@v3
-        with:
-          platforms: arm64
-
       - uses: pypa/[email protected]

fail-fast: false matters here: a broken cp39 wheel should not cancel the four interpreter versions that were about to succeed on the same tag.

Collecting and publishing

actions/upload-artifact@v4 requires unique artifact names, which is why each row uploads wheels-<tag>. The publish job pulls them back into one directory with merge-multiple.

  publish:
    needs: wheels
    runs-on: warp-ubuntu-latest-x64-2x
    environment: pypi
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: wheels-*
          path: dist
          merge-multiple: true

      - run: ls -l dist

      - uses: pypa/gh-action-pypi-publish@release/v1

The ls -l dist step is worth keeping. It is the cheapest check that the aarch64 wheels actually arrived, since a matrix row that silently produced nothing is otherwise invisible until an ARM64 user reports that pip install fell back to the source distribution.

Cost or Time Model

Assumptions, stated so you can substitute the two durations you measured in the diagnosis section:

  • One package with 3 C extension modules, built for 5 CPython versions, producing 5 manylinux_2_28_aarch64 wheels in one matrix row.
  • Example durations: 9 minutes per wheel emulated, 2 minutes per wheel native, plus 4 minutes of checkout, container pull, and auditwheel repair in both cases. These are placeholders that make the arithmetic visible. Your ratio depends on how much of the build is CPU bound inside the container.
  • That gives 49 billed minutes emulated and 14 billed minutes native for the row.
  • 40 release and pre-release builds per month.
  • WarpBuild rates from the pricing page, billed per minute.
PathArithmeticCost per buildMonthly at 40 builds
Emulated aarch64 on warp-ubuntu-latest-x64-8x49 x $0.016$0.784$31.36
Native aarch64 on warp-ubuntu-latest-arm64-8x14 x $0.012$0.168$6.72

Two levers move independently in that table. Removing the translation takes 35 billed minutes out of the row. The rate difference is worth $0.004 per minute at the 8 vCPU size, because the ARM64 rate sits below the x64 rate at every size in the catalog above.

Wall clock moves further than the bill does. The matrix rows run in parallel, so the release finishes when the slowest row finishes. With the aarch64 row at 49 minutes and the x86_64 row at roughly 14, the aarch64 row sets the tag-to-PyPI time on its own; native builds put the two rows within a few minutes of each other.

Against GitHub-hosted list prices

GitHub publishes its per-minute rates in the Actions minute multipliers reference, with shapes in the GitHub-hosted runners reference. At the same shape, warp-ubuntu-latest-arm64-8x (8 vCPU, 32 GB) costs $0.012 per minute against $0.014 per minute for the 8-core Linux ARM64 larger runner (8 vCPU, 32 GB): 14 percent lower list price. GitHub list price checked on 2026-08-13.

Applying that to the same 14 minute native row: $0.168 per build here against 14 x $0.014 = $0.196 per build, or $7.84 per month at 40 builds. Removing the emulation is the larger lever on either provider, and the per-minute rate stacks on top of it.

Every cost and performance number on this page carries a source link and a checked-on date, and the same number appears on every WarpBuild page that uses it, so you can verify it before planning around it.

FAQ

Why is the aarch64 leg of my wheel matrix so much slower than the x86_64 leg?

On Linux, cibuildwheel builds inside a manylinux or musllinux container whose architecture follows the host. On an x86_64 runner the aarch64 container only runs because docker/setup-qemu-action registered binfmt_misc handlers, so every compiler, linker, and test process inside it runs as translated aarch64 code. The x86_64 leg runs natively and pays none of that.

Do I still need docker/setup-qemu-action on a native ARM64 runner?

No. The runner is aarch64, so the manylinux_2_28_aarch64 container is a native container and needs no binfmt handler. Delete the step and set CIBW_ARCHS_LINUX to aarch64. Keep the QEMU step only in a job that still builds a foreign architecture on purpose.

Which platform tags does the aarch64 leg have to produce?

manylinux wheels carry a tag such as manylinux_2_28_aarch64 under PEP 600, and musl-based wheels carry musllinux_1_2_aarch64 under PEP 656. A glibc aarch64 wheel does not install on Alpine, so a project that supports both libc families needs both build identifiers in the matrix. The mapping from interpreter version to identifier to tag is in the matrix table above.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.