Repository: pypa/cibuildwheel

Issue:
Should arm64 runners build both universal2 and arm64 wheels by default?
Continuing the discussion from https://github.com/pypa/cibuildwheel/pull/1191#discussion_r928749039

The current situation is that arm64 runners builds universal2 and arm64 by default. This discussion is whether we change that to just arm64.

> @joerick: The question of whether to default building both universal2 and arm64 on the arm runner is still open, in my mind. I'm still mostly of the opinion that building both is good, because universal2 packages are preferred in some contexts (e.g. distributable GUI apps), and that most packages are small and the extra build time isn't much of a hit. Of course, people building big libraries would be wise to change this to just build arm64.

> @henryiii: Personally, I think the default should be native only. Eventually no one will care about Intel / universal and will only produce native binaries again. Also, there are still workarounds related to cross compiling for universal (like requiring all dependencies to also be universal). And we don’t support it yet, but there are two ways to make a universal binary, the default way and merging two native binaries. And finally universal vs. native is a choice, and I’d you pick universal, you don’t need native. You can always add tags for the native arch’s to a universal binary. And pip won’t even ever download universal if both native wheels exist.
> 
> So I’d recommend native being default, and only producing native binaries with it.

Let's get a decision on this before we release CirrusCI support, which would be our first officially supported arm64 CI.



Discussion:
Notably, numpy doesn't distribute universal2 wheels currently. There's some discussion about that here: https://github.com/numpy/numpy/pull/20787
Regarding wheel fusing (I had to look this up) - multibuild's implementation is here:

https://github.com/multi-build/multibuild/blob/10d9a4c9663759e194495e87eb84f96f7cb513e9/osx_utils.sh#L508-L525

It uses a tool built into delocate called [delocate-fuse](https://github.com/matthew-brett/delocate/blob/master/delocate/fuse.py), called like `delocate-fuse <x86_64_wheel> <arm64_wheel> -w <outdir>`.

I don't know how well this works, it seems like there _could_ be cases where this fails (particularly I'm wondering what happens we hit the `# existing not-lib file not identical to source` comment). But, given this is part of multibuild, it must have gotten a good amount of testing, and delocate has always been solid.

The other thing that's on my mind is whether we're even doing the universal2 thing at the right stage of the packaging process. As we know, universal2 wheels are ignored by pip when a native one is present. Packaged macOS apps are a pretty small percentage of Python deployment. If fusing is indeed possible, do we really need the universal2 wheels on PyPI at all? Can we push wheel fusing down the process to a distribution tool like py2app or pyinstaller? As @mattip says in the numpy thread linked above:

> @mattip: As I understand things, app packagers would still need to have the option to fuse wheels for projects that do not produce universal2 wheels, so I think the proper place for all that to happen is when packaging apps.

I suppose the only downside to this is that project-built universal2 wheels have the chance to be tested before release, whereas I might not be as confident in using a universal2 wheel that was fused 'in the wild'. I suppose that depends on how reliable the 'fuse' process is.

A final thought - is wheel fusing the only option for macOS packagers? Could they build/distribute separate x86_64 and arm64 versions?

> Can we push wheel fusing down the process to the packaging tool like py2app or pyinstaller?

+1 from me, until there is a real user-side demand for universal wheels.
IMO, this means (for cibuildwheel) that we should never build `universal2` by default, but leave it up to a user choice. So AS runners should try to build AS wheels by default, like Intel runners and Intel wheels. We could add a fuse option eventually, which would also allow us to test the fused wheel, but that's very much extra. Users can use delocate-fuse today if they want to - especially if they build on both Intel and Apple Silicon runners. Users can also just not ship Universal2 if they ship both. Maybe packaging could eventually fuze after the fact if they needed both.

PS: I would also recommend retagging a universal2 wheel like `macosx_10_10_universal2.macosx_10_10_x86_64.macosx_11_0_arm64.macosx_11_0_universal2` which allows a single wheel to also work with older pips, too. The tooling for that would be in https://github.com/pypa/wheel/pull/422 but wheel development is a bit slow at the moment.
What are "AS runners" and "AS wheels"? The plan sounds reasonable in any case.
AS is probably short for Apple Silicon.

Resolving patch:
diff --git a/cibuildwheel/architecture.py b/cibuildwheel/architecture.py
--- a/cibuildwheel/architecture.py
+++ b/cibuildwheel/architecture.py
@@ -66,10 +66,6 @@ def auto_archs(platform: PlatformName) -> set[Architecture]:
         if platform == "windows" and native_architecture == Architecture.AMD64:
             result.add(Architecture.x86)
 
-        if platform == "macos" and native_architecture == Architecture.arm64:
-            # arm64 can build and test both archs of a universal2 wheel.
-            result.add(Architecture.universal2)
-
         return result
 
     @staticmethod


Test patch:
diff --git a/test/test_macos_archs.py b/test/test_macos_archs.py
--- a/test/test_macos_archs.py
+++ b/test/test_macos_archs.py
@@ -11,7 +11,7 @@
 
 ALL_MACOS_WHEELS = {
     *utils.expected_wheels("spam", "0.1.0", machine_arch="x86_64"),
-    *utils.expected_wheels("spam", "0.1.0", machine_arch="arm64"),
+    *utils.expected_wheels("spam", "0.1.0", machine_arch="arm64", include_universal2=True),
 }
 
 
diff --git a/test/utils.py b/test/utils.py
--- a/test/utils.py
+++ b/test/utils.py
@@ -116,6 +116,7 @@ def expected_wheels(
     macosx_deployment_target="10.9",
     machine_arch=None,
     python_abi_tags=None,
+    include_universal2=False,
 ):
     """
     Returns a list of expected wheels from a run of cibuildwheel.
@@ -199,14 +200,14 @@ def expected_wheels(
                 arm64_macosx_deployment_target = _get_arm64_macosx_deployment_target(
                     macosx_deployment_target
                 )
-                platform_tags = [
-                    f'macosx_{macosx_deployment_target.replace(".", "_")}_universal2',
-                    f'macosx_{arm64_macosx_deployment_target.replace(".", "_")}_arm64',
-                ]
+                platform_tags = [f'macosx_{arm64_macosx_deployment_target.replace(".", "_")}_arm64']
             else:
-                platform_tags = [
-                    f'macosx_{macosx_deployment_target.replace(".", "_")}_x86_64',
-                ]
+                platform_tags = [f'macosx_{macosx_deployment_target.replace(".", "_")}_x86_64']
+
+            if include_universal2:
+                platform_tags.append(
+                    f'macosx_{macosx_deployment_target.replace(".", "_")}_universal2',
+                )
 
         else:
             raise Exception("unsupported platform")