Skip to content

Commit d1fddea

Browse files
committed
unix: support building Linux x86-64 from macOS aarch64
Docker on aarch64 macOS will automagically virtualize x86-64 containers if containers are spawned with `platform=linux/amd64`. Performance of spawned containers is a bit slower than native, but not horrible. This functionality means it is viable to develop Linux x86-64 from modern Apple hardware. This commit teaches the build system to support cross-compiling Linux x86-64 from macOS aarch64. Implementing this wasn't too difficult: we need to pass `platform` into Docker's APIs for building and creating containers. We need to teach code to resolve the effective host platform when this scenario is detected. And we need to advertise support for cross-compiling in the `targets.yml` file. In case you are wondering, yes, a similar solution could be employed for Linux too by using emulation. But this requires Docker be configured to support emulation, which isn't common. Rosetta on macOS "just works" and is therefore the lowest hanging fruit to implement.
1 parent 77c46f1 commit d1fddea

File tree

5 files changed

+86
-42
lines changed

5 files changed

+86
-42
lines changed

cpython-unix/build-main.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,14 @@ def main():
142142
effective_host_platform = host_platform
143143
if building_linux_from_macos:
144144
if host_platform == "macos_arm64":
145-
effective_host_platform = "linux_aarch64"
145+
if target_triple.startswith("aarch64"):
146+
effective_host_platform = "linux_aarch64"
147+
elif target_triple.startswith("x86_64"):
148+
effective_host_platform = "linux_x86_64"
149+
else:
150+
raise Exception(
151+
f"unsupported macOS cross-compile: {host_platform} -> {target_triple}"
152+
)
146153
else:
147154
raise Exception(f"Unhandled macOS platform: {host_platform}")
148155
print(

cpython-unix/build.py

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,20 @@
2525
meets_python_minimum_version,
2626
parse_setup_line,
2727
)
28-
from pythonbuild.docker import build_docker_image, get_image, write_dockerfiles
28+
from pythonbuild.docker import (
29+
build_docker_image,
30+
docker_platform_from_host_platform,
31+
get_image,
32+
write_dockerfiles,
33+
)
2934
from pythonbuild.downloads import DOWNLOADS
3035
from pythonbuild.logging import log, set_logger
3136
from pythonbuild.utils import (
3237
add_env_common,
3338
add_licenses_to_extension_entry,
3439
clang_toolchain,
3540
create_tar_from_directory,
41+
current_host_platform,
3642
download_entry,
3743
get_target_settings,
3844
get_targets,
@@ -98,21 +104,33 @@ def add_target_env(env, build_platform, target_triple, build_env, build_options)
98104
extra_target_ldflags.append("--rtlib=compiler-rt")
99105

100106
if build_platform.startswith("linux_"):
101-
machine = platform.machine()
107+
# autoconf is not aware of microarch triples. Normalize those out:
108+
# we force targeting via -march CFLAG.
109+
env["TARGET_TRIPLE"] = (
110+
target_triple.replace("x86_64_v2-", "x86_64-")
111+
.replace("x86_64_v3-", "x86_64-")
112+
.replace("x86_64_v4-", "x86_64-")
113+
)
102114

103-
# arm64 allows building for Linux on a macOS host using Docker
104-
if machine == "aarch64" or machine == "arm64":
105-
env["BUILD_TRIPLE"] = "aarch64-unknown-linux-gnu"
106-
env["TARGET_TRIPLE"] = target_triple
107-
elif machine == "x86_64":
108-
env["BUILD_TRIPLE"] = "x86_64-unknown-linux-gnu"
109-
env["TARGET_TRIPLE"] = (
110-
target_triple.replace("x86_64_v2-", "x86_64-")
111-
.replace("x86_64_v3-", "x86_64-")
112-
.replace("x86_64_v4-", "x86_64-")
113-
)
115+
# On macOS, we support building Linux in a virtualized container that
116+
# always matches the target platform. Set build/host triple to whatever
117+
# we're building.
118+
#
119+
# Note: we always use the *-gnu triple otherwise autoconf can have
120+
# trouble reasoning about cross-compiling since its detected triple from
121+
# our build environment is always GNU based.
122+
if current_host_platform().startswith("macos_"):
123+
env["BUILD_TRIPLE"] = env["TARGET_TRIPLE"].replace("-musl", "-gnu")
114124
else:
115-
raise Exception("unhandled Linux machine value: %s" % machine)
125+
# Otherwise assume the container environment matches the machine
126+
# type of the current process.
127+
host_machine = platform.machine()
128+
if host_machine == "aarch64" or host_machine == "arm64":
129+
env["BUILD_TRIPLE"] = "aarch64-unknown-linux-gnu"
130+
elif host_machine == "x86_64":
131+
env["BUILD_TRIPLE"] = "x86_64-unknown-linux-gnu"
132+
else:
133+
raise Exception("unhandled Linux machine value: %s" % host_machine)
116134

117135
# This will make x86_64_v2, etc count as cross-compiling. This is
118136
# semantically correct, since the current machine may not support
@@ -960,16 +978,6 @@ def main():
960978
DOWNLOADS_PATH.mkdir(exist_ok=True)
961979
(BUILD / "logs").mkdir(exist_ok=True)
962980

963-
if os.environ.get("PYBUILD_NO_DOCKER"):
964-
client = None
965-
else:
966-
try:
967-
client = docker.from_env(timeout=600)
968-
client.ping()
969-
except Exception as e:
970-
print("unable to connect to Docker: %s" % e, file=sys.stderr)
971-
return 1
972-
973981
# Note these arguments must be synced with `build-main.py`
974982
parser = argparse.ArgumentParser()
975983
parser.add_argument(
@@ -1031,6 +1039,17 @@ def main():
10311039

10321040
settings = get_target_settings(TARGETS_CONFIG, target_triple)
10331041

1042+
if os.environ.get("PYBUILD_NO_DOCKER"):
1043+
client = None
1044+
else:
1045+
try:
1046+
client = docker.from_env(timeout=600)
1047+
client.ping()
1048+
client._pbs_platform = docker_platform_from_host_platform(host_platform)
1049+
except Exception as e:
1050+
print("unable to connect to Docker: %s" % e, file=sys.stderr)
1051+
return 1
1052+
10341053
if args.action == "dockerfiles":
10351054
log_name = "dockerfiles"
10361055
elif args.action == "makefiles":

cpython-unix/targets.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,7 @@ x86_64-apple-darwin:
556556
x86_64-unknown-linux-gnu:
557557
host_platforms:
558558
- linux_x86_64
559+
- macos_arm64
559560
pythons_supported:
560561
- '3.10'
561562
- '3.11'
@@ -605,6 +606,7 @@ x86_64-unknown-linux-gnu:
605606
x86_64_v2-unknown-linux-gnu:
606607
host_platforms:
607608
- linux_x86_64
609+
- macos_arm64
608610
pythons_supported:
609611
- '3.10'
610612
- '3.11'
@@ -655,6 +657,7 @@ x86_64_v2-unknown-linux-gnu:
655657
x86_64_v3-unknown-linux-gnu:
656658
host_platforms:
657659
- linux_x86_64
660+
- macos_arm64
658661
pythons_supported:
659662
- '3.10'
660663
- '3.11'
@@ -705,6 +708,9 @@ x86_64_v3-unknown-linux-gnu:
705708
x86_64_v4-unknown-linux-gnu:
706709
host_platforms:
707710
- linux_x86_64
711+
# Rosetta doesn't support AVX-512. So we cannot run x86-64-v4 binaries
712+
# under Rosetta. But they can build correctly.
713+
- macos_arm64
708714
pythons_supported:
709715
- '3.10'
710716
- '3.11'
@@ -755,6 +761,9 @@ x86_64_v4-unknown-linux-gnu:
755761
x86_64-unknown-linux-musl:
756762
host_platforms:
757763
- linux_x86_64
764+
# Rosetta doesn't support AVX-512. So we cannot run x86-64-v4 binaries
765+
# under Rosetta. But they can build correctly.
766+
- macos_arm64
758767
pythons_supported:
759768
- '3.10'
760769
- '3.11'
@@ -802,6 +811,7 @@ x86_64-unknown-linux-musl:
802811
x86_64_v2-unknown-linux-musl:
803812
host_platforms:
804813
- linux_x86_64
814+
- macos_arm64
805815
pythons_supported:
806816
- '3.10'
807817
- '3.11'
@@ -850,6 +860,7 @@ x86_64_v2-unknown-linux-musl:
850860
x86_64_v3-unknown-linux-musl:
851861
host_platforms:
852862
- linux_x86_64
863+
- macos_arm64
853864
pythons_supported:
854865
- '3.10'
855866
- '3.11'
@@ -898,6 +909,7 @@ x86_64_v3-unknown-linux-musl:
898909
x86_64_v4-unknown-linux-musl:
899910
host_platforms:
900911
- linux_x86_64
912+
- macos_arm64
901913
pythons_supported:
902914
- '3.10'
903915
- '3.11'

pythonbuild/buildenv.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,10 @@ def find_output_files(self, base_path, pattern):
266266
def build_environment(client, image):
267267
if client is not None:
268268
container = client.containers.run(
269-
image, command=["/bin/sleep", "86400"], detach=True
269+
image,
270+
command=["/bin/sleep", "86400"],
271+
detach=True,
272+
platform=client._pbs_platform,
270273
)
271274
td = None
272275
context = ContainerContext(container)

pythonbuild/docker.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
# License, v. 2.0. If a copy of the MPL was not distributed with this
33
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

5-
import contextlib
65
import io
76
import operator
87
import os
98
import pathlib
109
import tarfile
10+
import typing
1111

1212
import docker # type: ignore
1313
import jinja2
@@ -29,16 +29,31 @@ def write_dockerfiles(source_dir: pathlib.Path, dest_dir: pathlib.Path):
2929
write_if_different(dest_dir / f, data.encode("utf-8"))
3030

3131

32+
def docker_platform_from_host_platform(host_platform: str) -> typing.Optional[str]:
33+
"""Convert a PBS host platform to a Docker platform."""
34+
if host_platform == "linux_x86_64":
35+
return "linux/amd64"
36+
elif host_platform == "linux_aarch64":
37+
return "linux/arm64"
38+
else:
39+
return None
40+
41+
3242
def build_docker_image(
3343
client, image_data: bytes, image_dir: pathlib.Path, name, host_platform
3444
):
3545
image_path = image_dir / f"image-{name}.{host_platform}"
3646

37-
return ensure_docker_image(client, io.BytesIO(image_data), image_path=image_path)
47+
return ensure_docker_image(
48+
client,
49+
io.BytesIO(image_data),
50+
image_path=image_path,
51+
platform=docker_platform_from_host_platform(host_platform),
52+
)
3853

3954

40-
def ensure_docker_image(client, fh, image_path=None):
41-
res = client.api.build(fileobj=fh, decode=True)
55+
def ensure_docker_image(client, fh, image_path=None, platform=None):
56+
res = client.api.build(fileobj=fh, decode=True, platform=platform)
4257

4358
image = None
4459

@@ -111,18 +126,6 @@ def copy_file_to_container(path, container, container_path, archive_path=None):
111126
container.put_archive(container_path, buf.getvalue())
112127

113128

114-
@contextlib.contextmanager
115-
def run_container(client, image):
116-
container = client.containers.run(
117-
image, command=["/bin/sleep", "86400"], detach=True
118-
)
119-
try:
120-
yield container
121-
finally:
122-
container.stop(timeout=0)
123-
container.remove()
124-
125-
126129
def container_exec(container, command, user="build", environment=None):
127130
# docker-py's exec_run() won't return the exit code. So we reinvent the
128131
# wheel.

0 commit comments

Comments
 (0)