rust-lang / cargo

The Rust package manager
https://doc.rust-lang.org/cargo
Apache License 2.0
12.56k stars 2.38k forks source link

Support for macOS Universal/fat binaries #8875

Open kornelski opened 3 years ago

kornelski commented 3 years ago

macOS (and iOS) has a concept of universal binaries which contain code for multiple CPU architectures in the same file. Apple is migrating from x86_64 to aarch64 CPUs, so for the next few years it will be important for macOS developers to build "fat" binaries (executables and cdylibs).

Apple's Xcode has very helpful default behavior for this: when building in release mode, it automatically builds for both x86_64 and aarch64 together. In the debug mode, like Cargo, Xcode builds only the current native architecture.

Could cargo --release on macOS hosts automatically build both x86_64-apple-darwin and aarch64-apple-darwin, and merge them into a single executable? Merging requires running lipo -create -output universalbinary intelbinary armbinary.

I think it support for universal binaries should be built-in in Cargo:

  1. For the next 3-5 years it will be a necessary operation for every macOS release build.
  2. Cargo lacks support for general-purpose post-linking steps (#545). That issue has been in limbo for years, but arm64 (M1) Macs have already shipped, and support for Universal binaries is needed right now.
  3. Even if Cargo did have post-build steps, it would be chore to re-add the same necessary step to every project.
  4. There's a huge value in cargo build --release working for projects out of the box. Without building Universal binaries this becomes half-built, and insufficient for macOS developers.
alexcrichton commented 3 years ago

Seems reasonable to me to support! Cargo already had (unstable) support to build multiple targets at once, and it sounds like that's almost exactly what this wants (with just one final step). I think the first step here would be for a proposal to be made followed by an unstable implementation.

MarnixKuijs commented 3 years ago

To add to this, its seems currently when trying to link a universal binary it will fail with the following error:

failed to add native library [library path] file too small to be an archive

It would be nice if if I could just link towards a universal binary and cargo would be able to link it. This will probably have a lot more edge cases since there are multiple ways too link a library. Currently the alternative is to add a build step using lipo -thin and while this isn't too bad it is a chore too keep re-adding that step.

teor2345 commented 3 years ago

Could cargo --release on macOS hosts…

Can we also add a way to cross-compile to a macOS universal binary?

I know of a few projects that build their macOS binaries on Linux - they would also want a way to build universal binaries.

Specifically:

Does specifying apple-darwin actually do anything right now?

awakecoding commented 3 years ago

Is this issue about being able to link to fat libraries, or about producing fat libraries without having to manually call lipo post-build in cargo? Both are a problem, but the most pressing one is just being able to link against fat libraries without the "file too small to be an archive" error.

kornelski commented 3 years ago

I've meant this as a feature request for building fat binaries. I think "failed to add native library" could be considered a bug/incompatibility, and handled separately.

awakecoding commented 3 years ago

@kornelski you can use cargo-lipo today to simplify the process, but you'll still be forced to link against thin libraries because of a long-time linker issue. The linker currently doesn't handle the fat library header correctly, which was a very annoying problem for iOS, but now I guess it's going to become an even bigger problem with macOS and the ARM transition. It was a problem for macOS when we still made fat libraries for intel 32-bit and 64-bit, but since 32-bit was dropped most macOS libraries have become thin libraries. This is no longer the case because of ARM64, so we'd really appreciate a fix for the linker as a first step to make this more pleasant :)

luojia65 commented 3 years ago

How to achieve this? In my opinion, it may require to build two binary files, and do some work after building is finished. Can we achieve this by adding a cargo script, to finish generating the output file after all binaries (in this case, same code but different target) are built?

awakecoding commented 3 years ago

How to achieve this? In my opinion, it may require to build two binary files, and do some work after building is finished. Can we achieve this by adding a cargo script, to finish generating the output file after all binaries (in this case, same code but different target) are built?

It is better to simply build once per architecture and then combine the single-arch binaries (thin) into a multi-arch binary (fat). See my previous answer for how it can be done today using cargo-lipo, but there is also a long-standing linker bug that prevents linking against fat libraries.

EwoutH commented 3 years ago

I support this proposal and think that cargo build --release should build for the same targets as xcodebuild -configuration Release does on its stable version. Since Xcode 12.2, this is x86_64 and arm64.

Xcode 12.2 and later automatically adds the arm64 architecture to the list of standard architectures for all macOS binaries, including apps and libraries. During the debugging and testing process, Xcode builds only for the current system architecture by default. However, it automatically builds a universal binary for the release version of your code.

building-a-universal-macos-binary-1@2x

At some point Xcode will remove x86_64 from their list of standard release architectures, and at that point (or a certain period after) cargo build --release should be updated to reflect that and only build for arm64.

kornelski commented 3 years ago

I wonder at which level it should be done in Cargo, given that Rust has a concept of a target, and obviously it'd be very weird if x86_64-apple-darwin target built things for ARM (and ARM-only eventually).

I don't think universal-apple-darwin would make sense to exist as a Rust target, because it completely doesn't fit what Rust considers a target.

So I suppose all the universal magic would have to be limited to invocation of cargo build/run pretty early, at a high level, so that Cargo itself would change it to be equivalent to cargo build --target=x86_64-apple-darwin + cargo build --target=aarch64-apple-darwin running together.

awakecoding commented 3 years ago

@kornelski I second this, we shouldn't add a "universal" target, because it just makes everything much more complicated, without considering the fact that "universal" is not even a target, and could mean more than one thing (it is a combination of multiple targets, but we don't know which ones).

I think the current cargo-lipo solution could simply be ported directly into cargo instead of being a separate tool: basically keep building for one target at a time, but make it possible to call cargo telling it to build for multiple targets and produce universal binaries. Under the hood all it does is build for each target + combine the multiple thin binaries into a single fat binary using lipo.

The only downside is that this won't solve the problem of cross-compiling, but there are many other issues that make this quite difficult anyway, starting by the availability of tools like "lipo" outside of Xcode on macOS. This tool is open sourced by Apple as part of the cctools package, but they never bothered porting it to other platforms themselves. There exists a Linux port of cctools with a copy of lipo I have successfully used myself: https://github.com/tpoechtrager/cctools-port

I don't think we need to go through all this trouble, a first improvement to include the functionality from cargo-lipo directly into cargo while relying on the presence of a "lipo" command-line tool (we could support it on Linux if you install the cctools-port) would already be more than good enough.

messense commented 3 years ago

FYI, I have implemented a "lipo" like crate recently: https://github.com/messense/fat-macho-rs

cormacrelf commented 3 years ago

I think we may have circled all the way back to @ alexcrichton's initial response at the top of the issue. https://github.com/rust-lang/cargo/issues/8176 is what he was referring to by existing unstable multi target support. You would just need a way to tell cargo to add the build step afterwards.

I don't think it should be a default for all release builds on macOS. While you can run benchmarks with cargo, perhaps the most common reason for building in release mode is to test speed. I don't want to double the LLVM codegen time and linking time which is already significantly slower on macOS (ld64) without lld support for mach-o, just so people don't have to change their dist build script to add some flag. I'm pretty sure every single distribution of code to macOS has had to touch their release scripts.

Raw suggestion which is quite extensive and a lot of work probably? But have a look:

[lib]
...
[lib.targets.macos-universal]
crate-type = ["...", "..."]
targets = ["x86_64-apple-darwin", "aarch64-apple-darwin"]

# and either
post-build = "lipo_universal.rs"
# runs lipo on the outputs that it reads from env variables.
# Pretty easy to write this.
# Then you can use this for arbitrary post-processing including strip, etc...

# or supply a list of builtin post-processing steps.
post-build = ["lipo"]

# or both! Cargo's builtins run first. Need to name them differently. Or recognise file names ending with .rs and have them all in the one list.

Some questions/thoughts:

Ok big pie on the sky idea here, but:

Ok, I'll stop there.

luojia65 commented 3 years ago

Post build script is a more general way. On other platforms we also need a way to fuse code from different architecture, or we can do more work like what we already did in build.rs.

cormacrelf commented 3 years ago

Some further comments on reflection:

kornelski commented 3 years ago

I think involving custom post-build scripts here is a mistake. This problem is not a custom job, it's a common requirement for an entire platform.

Note that the baseline for this is something like:

build.sh:

cargo build --target=aarch64-apple-darwin
cargo build --target=x86_64-apple-darwin
lipo -create foo target/aarch64-apple-darwin/release/foo target/x86_64-apple-darwin/release/foo

so if users had to write custom TOML config or custom scripts in Rust that implement the same thing, it wouldn't simplify anything.

And trying to solve all the problems of wrangler, cbindgen, IDE integration, etc. at the same time will mean this issue will be paralyzed by additional incompatible requirements, scope creep, and won't get done (at least not before Apple drops x86 support making it moot ;)

kornelski commented 3 years ago

For configuration, I suggest:

It could be controlled with:

cargo build --no-apple-universal --release cargo build --apple-universal

and

[profiles.dev]
apple-universal = true

[profiles.release]
apple-universal = false

or apple-universal = ["aarch64", "x86-64"], but this may be unnecessary, since currently there are no other archs that Apple supports. It could be added later in case Apple decided to change their CPUs once again :)

regexident commented 3 years ago

The lipo workflow for creating fat universal .frameworks has been deprecated at WWDC'19 in favor of .xcframeworks:

lipo \
  -create \
  <PATH> \
  <PATH> \
  -output <PATH>
xcodebuild \
  -create-xcframework \
  -framework <PATH> \
  -framework <PATH> \
  -output <PATH>
kornelski commented 3 years ago

AFAIK xcframeworks are not for releases for end-users to use, but for XCode's package manager and bundling libraries and header files together. At best they're like fancy .app bundle, not a binary. Cargo builds binaries.

BTW: it's super annoying that Apple doesn't announce their OS changes in writing, and WWDC developer marketing videos are used instead of written changelogs and technical documentation.

regexident commented 3 years ago

@kornelski That's correct indeed.

But since .xcframework is the only way to make a swift package depend on a (.dylib or .a) library (such as those produced by cargo), I'd argue that whether .xcframework is a simple binary format or actually much more than a mere binary (which was the case for .framework, too) does not really matter.

The standard method for distributing libraries for iOS/macOS has always been .framework, not .a or .dylib, for what it's worth.

ghost commented 2 years ago

@kornelski So what is the procedure to make a universal binary right now?

fdv1 commented 2 years ago

@kornelski I'm also interested to know the answer to this question.

awakecoding commented 2 years ago

@Voodlaz @fdv1 the simplest way to produce a universal binary right now is to use a tool like cargo-lipo which builds once for each target and then calls lipo to merge the binaries together. You can also call lipo directly to do the same, if you need lipo on non-macOS, it's possible to either use a Linux port of the cctools or use llvm-lipo which is not built in regular clang+llvm distributions. I have both of these in my own clang+llvm builds if anyone needs them.

ghost commented 2 years ago

@awakecoding but cargo-lipo is deprecated in favor of doing it through Xcode, as it's written on the github page. So that's why I'm asking what is the procedure to make a universal binary RIGHT NOW(bad pharsing ig, should've used currently). Also, cargo-lipo if I'm not being wrong, is for ios. Will be there any problems with using it for Mac OS?

Absolucy commented 2 years ago

cargo lipo doesn't work on executables, last I checked. It only does staticlibs, so it's not really a universal solution.

awakecoding commented 2 years ago

@Voodlaz got a link to the github page? Even if Xcode has a list of targets to build for, only AppleClang has built-in support for those, a toolchain like Rust needs to build once for every architecture and then merge with lipo. The original lipo (and the portable replacements, like cctools for Linux and llvm-lipo) should work just fine on static libraries, shared libraries and executables. Maybe cargo-lipo needs some love, but calling lipo should work on macOS, iOS, and for all binary types.

ghost commented 2 years ago

@awakecoding https://github.com/timnn/cargo-lipo

awakecoding commented 2 years ago

I haven't use cargo lipo in a while, since we ended up doing single-architecture builds that we merge using lipo post-build for all of our Rust projects (both macOS and iOS). The issue with cargo-lipo is that the developer only cared for iOS when in fact it's the same thing for macOS. You don't need Xcode at all to produce universal binaries, the only thing you need is a functional lipo command-line tool and some sort of wrapper script to do the merge post-build. cargo-lipo was just a wrapper to do it through cargo, but it is apparently unmaintained now.

ghost commented 2 years ago

@awakecoding Okay

kornelski commented 2 years ago

I've updated cargo xcode to support lipo for both iOS and macOS.

alex commented 2 years ago

Was a separate issue ever filed for the "unable to link against a universal static archive"?

If not, I'll file one but I want to make sure I didn't miss one in the thread!

iMonZ commented 2 years ago

Anything new here?

alex commented 2 years ago

On deeper investigation the existing rustc bug (https://github.com/rust-lang/rust/issues/55235) is probably the best place to track this -- ability to link static .as into libraries seems to be a rustc bug/feature, not a cargo one.

rodrigc commented 1 year ago

@randomairborne came up with a simple tool to build universal binaries: https://github.com/randomairborne/cargo-universal2

goingforbrooke commented 1 year ago

For a quick fix, the Golang rewrite of lipo is great for running locally or in a GitHub Action. The latter's particularly interesting because MacOS runners are expensive.

Tools like cargo-lipo, and Tauri's bundler use lipo behind the scenes for commands like tauri build --target universal-apple-darwin. Tauri's bundler is a fork of cargo-bundle, which doesn't support universal binaries.

I'm considering RIIR lipo since it was open sourced and adding it to cargo-bundle. Thoughts?

NobodyXu commented 1 year ago

I think llvm-lipo is also another alternative to macOS lipo.

belkadan commented 1 year ago

This is a year late, but xcframeworks are for bundling multiple platforms together (like simulator and device, or macOS and iOS). Universal binaries are still the recommended way to have multiple architectures for one platform.

tuzz commented 1 year ago

To add to this, it would be nice if the universal binary only included a single copy of include_bytes! and include_str! data. Two copies are placed into the universal binary when you use the lipo command which seems wasteful.

goingforbrooke commented 1 year ago

To add to this, it would be nice if the universal binary only included a single copy of include_bytes! and include_str! data. Two copies are placed into the universal binary when you use the lipo command which seems wasteful.

Intriguing. Do you know of a place where I can read more about this?

AdrianEddy commented 1 year ago

Well the universal binary is basically an archive containing multiple binaries for target architectures. Given that each architecture is compiled separately and only at the end they are bundled, it's not surprising that the bytes are included twice. You can easily extract separate binaries from the universal one and they are completely standalone for given architecture