Pitch: Distributing Swift macros as prebuilt binaries

Introduction

Swift macros today can only be consumed as a .macro target built from source. There is no supported way to ship a macro's implementation as a prebuilt binary. This pitch proposes a small addition to the artifact-bundle format and SwiftPM so that a macro implementation can be distributed as a prebuilt binary - consumed with the same .binaryTarget(url:checksum:) mechanism already used for XCFrameworks - while the macro's declaration continues to ship in a library's module interface.

Motivation

Macros are increasingly part of library and SDK public APIs. But a macro is two halves:

  • The declaration - macro foo(...) = #externalMacro(module: "FooMacros", ...); this is just the API and _can_ be distributed inside an XCFramework or any other .swiftinterface
  • The implementation - struct FooMacro: ...; this is the executable run by the compiler at build time. Currently this can only be compiled from source

This creates two problems:

  1. Binary SDKs can't ship macros at all. A vendor distributing precompiled XCFrameworks can put the macro declaration in the .swiftinterface, but has nowhere to put the implementation. In practice they must publish the macro source in a separate, consumer-facing package and have every consumer recompile it - duplicating and re-maintaining the macro in two places. This pitch comes directly out of a real SDK that ships as XCFrameworks and hit exactly this: the same macros maintained twice, in the SDK source repo and in the public consumer package.

  2. Every consumer recompiles the macro (and swift-syntax) from source. Even for source-distributed packages this is a well-known build-time cost. The swift-syntax prebuilts feature removes the swift-syntax compile, but the macro itself is still compiled from source by every consumer, and that feature is toolchain-managed and hardcoded to swift-syntax - it does not let a third party ship their own macro as a binary.

Why not "just put it in the XCFramework"?

A question for people like me as this is where I originally started. A macro plugin is a host tool: it runs on the machine performing the compile, regardless of what the build targets. An XCFramework is organised by target triple (the device the code runs on) and its format has no slot for a host executable. Artifact bundles (SE‑0305) are the host‑keyed, multi‑triple container for executables - so the macro implementation rides alongside the library bundle, not inside the XCFramework.

Proposed solution

Add a macro artifact type to the artifact-bundle info.json, with host-keyed variants - just like the existing executable type:

{
  "schemaVersion": "1.0",
  "artifacts": {
    "FooMacros": {
      "type": "macro",
      "version": "1.0.0",
      "variants": [
        { "path": "arm64-apple-macosx/FooMacros", "supportedTriples": ["arm64-apple-macosx"] },
        { "path": "aarch64-unknown-linux-gnu/FooMacros",  "supportedTriples": ["aarch64-unknown-linux-gnu"] }
      ]
    }
  }
}

A consumer references it with the existing binary-target API - no new manifest surface:

.binaryTarget(name: "FooMacros", url: "https://.../FooMacros.artifactbundle.zip", checksum: "…"),
.target(name: "App", dependencies: ["FooLibrary", "FooMacros"]),

SwiftPM selects the variant matching the host, and passes -load-plugin-executable #FooMacros to the compiler - exactly the mechanism it already uses for a source-built .macro target. The macro declaration still comes from the library (#externalMacro(module: "FooMacros", …) in its .swiftinterface). Nothing about authoring or using a macro changes; only how the implementation is delivered.

Detailed design

The change for this was actually pretty small and reuses a lot of existing infrastructure to make it work. The changes affect `swift build` and `swift-package-manager`:

  • swift-build - the PR for this is a one liner to add SWIFT_LOAD_BINARY_MACROS to ProjectModel.BuildSettings.MultipleValueSetting. This ensures the setting survives PIF encode and decode
  • swift-package-manager - the PR for this is a bit more complex, but not overly so. Essentially it just hooks everything up so that the macro works in a binary artifact and the correct settings are passed to the compiler.

I've tested this on both macOS (arm64) and Linux (aarch64), with both the native and the default SwiftBuild build engines, including consumption from a real GitHub release. The PoC just vends the macro declaration in a target, but I've tested this via an XCFramework as well on both platforms.

How to try it

See the PoC repo's README. In short: clone the two forks, point SwiftPM at the local swift-build fork (swift package edit), build swift-run, then swift run --package-path RemoteConsumer App - which pulls a macro from a GitHub release via url:checksum: and expands it, printing value=42 source=40 + 2, with no macro source compiled by the consumer.

Alternatives considered

There are number of alternatives that could be done instead from the fragile (setting compiler flags) to the very large (extending XCFrameworks) to brand new APIs. But this was a much easier, and stable solution.

One interesting future direction could be the future improvements to prebuilts, but currently there is no way to publish your own macro binaries and these changes will still be required.

Future directions

Consuming these binary artifacts is pretty easy. Publishing them is a little more complicated and involves building for each host target and combining them together. It would be nice to eventually hook into the cross-compilation work and have it all done with one command, rather than hand-rolling the artifact (see the script in the PoC). This would be similar to how XCFrameworks are created with xcodebuild.

Additionally the published binary is tied to a Swift toolchain version. The PoC only uses the triple to key variants, but something like the swift-syntax prebuilts manifest has better options to key on compiler version as well to avoid compile time issues due to mismatched toolchains.

17 Likes

Like you point out in the future directions, I too worry about the feasibility of publishing the macro binaries for package authors. IIUC, it puts developers with Linux hosts at a disadvantage since macOS isn't supported as a target platform unless you're on a macOS host? And I wouldn't know where to begin as a macOS user if I wanted to support Windows hosts.

There was a proof-of-concept a couple years ago that built macros for WebAssembly. Is that something we should be investing more in? Anyone can cross-compile to it, it would mean that package authors only have to target a single host for prebuilts instead of many, and we'd get some other things like sandboxing for free.

The implementation changes you linked to are small enough that I think it would be silly to say we shouldn't add the support you're proposing, but I wonder how much it realistically moves the needle. Packages that target iOS/macOS/watchOS/tvOS/visionOS probably benefit the most from this since building a macOS binary is trivial for those developers, and maybe that's a large enough group to justify it.

I suppose the code signing requirements for these executables are the same as what's already required for .xcframeworks?

8 Likes

Yes they work in a very similar way to XCFrameworks in a number of aspects

This is true, but it's no different to XCFrameworks - you have to build them on macOS to be useable on macOS and Linux to be usable on Linux. Distribution also has the same issues, and we end up doing:

let myFramework = XCFramework(
    name: "MyFramework",
    macosUrl: "https://api.github.com/repos/org/repo/releases/assets/12345",
    macosChecksum: "7dcc90a83230fca7572275e226544bd721dc724b9b5ae7c3ef335f22291aa07e",
    linuxUrl: "https://api.github.com/repos/org/repo/releases/assets/67890",
    linuxChecksum: "36a0a41f2ad1338eeab7fb92f6451ba9a72302a1dfddec878080ff840e092546"
)

with

struct XCFramework {
    enum Platform {
        case mac
        case linux
    }

    let name: String
    let macosUrl: String
    let macosChecksum: String
    let linuxUrl: String
    let linuxChecksum: String

    func remoteTarget() -> Target {
        #if os(Linux)
        return .binaryTarget(
            name: name,
            url: linuxUrl.appending("-\(name)-linux-\(version).zip"),
            checksum: linuxChecksum
        )
        #else
        return .binaryTarget(
            name: name,
            url: macosUrl.appending("-\(name)-macos-\(version).zip"),
            checksum: macosChecksum
        )
        #endif
    }
}

We'd use this in exactly the same way. Any changes to make bundling binary artifacts easier would likely solve this problem with XCFrameworks as well.

I'd not actually seen this. Compiling for all platforms would be great, but it looks like it would require some significant work to fully integrate it.

To be clear, this is a current problem for anyone using macros inside an XCFramework and wanting to expose them. This is a real-world problem we hit, which prompted the pitch. Currently we have a number of macros (e.g. custom logging solutions) that are used and defined inside the XCFramework, which is then distributed to downstream dependencies, and end-users where we don't want to ship, or cannot ship, source code. Currently we need to reimplement these macros in a public package so they can be consumed downstream. This means we have two implementations of the same macro, and any changes need to be duplicated in both places. This pitch solves that problem for us and anyone in the same situation.

1 Like

Can a macro binary for Linux not be cross-compiled and linked on a macOS host using a swift.org toolchain and the appropriate SDK?

1 Like

I don't actually know. I'll need to check how the macro executable is treated. There shouldn't be a reason why it wouldn't work, but I can see the static SDK compilation not including anything the macro executable would need to be invoked by or interface with the Swift toolchain as opposed to a regular executable that doesn't need to know about it

xcframeworks are really not designed for this. We should be avoiding using them for any purpose beyond distributing frameworks for various Apple platforms.

I'd go a bit further and suggest artifact bundles are also a violation of this architecture. They tried to bring a xcframeworks like concept to other platforms but I believe is solving the wrong problem. It was made clear when I saw people starting to put other binary artifacts into the same bundle even though binaryTargets suggest there should only ever be one. There's just so much magic happening behind the scenes. These things all need to be first class elements in the package model, not in some secondary file users generally can't see.

Instead, I believe we are missing is a generalized prebuilt strategy similar to what we have with swift-syntax, since that is really what you're trying to accomplish here. What I am hoping to see is to be able to take any archive containing binary targets and add package manifest declarations introducing those targets into the package graph. You would then specify build settings on those targets to allow the build system to use them. And you could have more than one of them in the archive. And you can associate it back to the source so if you don't have a binary for the current platform you can fall back to building it from source (the real super power of the swift-syntax prebuilts solution).

So, I guess it's obvious I've been thinking about this problem and have partial solutions in mind that hopefully result in a much cleaner architecture. Let me write those up and we can discuss in another post.

8 Likes

And I do recognize the need to ship binaries without source. We'll make sure the source side of this is optional. But we keep adding one binary type at a time which as I mentioned is really extending artifact bundles beyond their architecture. Each binary artifact type has different semantics, and lumping them all into a "binaryTarget" element feels like we can do better.

Let's solve the binary dependencies at once with a common mechanism that lets you declare them in the manifest without having to create a specialized format for SwiftPM. As I think about all the different requirements here a common solution does jump out and may actually produce something really good. Let me tie up a few bows and present what I'm thinking next week. This is the right time in the 6.5 cycle to do something big. :slight_smile: .

4 Likes

Packaging the binaries is a real pain point (see the gymnastics the script has to do to make it all work and bundle them together) so I fully support any efforts to improve this! There is definitely a need to solve general binary dependencies to cover all the use cases, interested to see what you come up with!

I would like to see what we can do to solve the problem today, because best case 6.5 is 9 months away and we have a real gap that is causing pain points and would be great if we could solve it sooner

2 Likes

BTW, this is starting to dovetail nicely into my work on external builders which has a similar problem, how to integrate binary artifacts from outside and integrate them into the package build. I have an experimental PR started for that [WIP] External builder plugin by dschaefer2 · Pull Request #10198 · swiftlang/swift-package-manager · GitHub. And something else I need to post on.

My focus is on 6.5. You'll need to run this by the Build and Packaging Working Group. From the discussion we had in our meeting this week (which is open to everyone interested), given this will likely require an evolution proposal, it may be hard to get this into 6.4 at this point.

3 Likes

I’d really like to see the WASM approach picked back up. It seemed the most promising to me.

FWIW @Max_Desiatov has been iterating on my old Wasm macros PR recently. I’m biased but agree it would be great to see that upstreamed! Last I left off the concern was WasmKit may not have been battle-tested enough to embed in the Swift toolchain as de-facto wasm runtime, but it seems that may be changing. (Max/other Apple folks: do let me know if this is indeed the case, I’d be happy to pitch in again.)

2 Likes

TL;DR: Swift macros prebuilt to Wasm is a promising approach that still needs some consideration. A lot of things need to fall into place for this to work smoothly.

WasmKit has been included in the swift.org toolchain since Swift 6.2, but it hasn't been included in the Xcode toolchain.

While it's great for sandboxing and solves Swift Syntax distribution problems, there are two other concerns:

  1. WasmKit interpreter is ~3-5x slower an overage than equivalent native code, which roughly corresponds to the fetch-decode-execute native instruction cycles that interpreters need to "emulate" for every Wasm instruction. This is pretty much the computational ceiling for the interpreter approach. For a small number of macro expansions evaluated it doesn't matter much, but for packages that have thousands of macro expansions per build it becomes noticeable on every build without any kind of caching (and IIUC macro expansions currently aren't cached). At the same time, if you saved ~5 minutes on a Swift Syntax build and gain sandboxing together with determinism by using Wasm, it's much easier to cache macro expansions and to amortize that cost. There are also possible Wasm -> native compilation approaches that can be considered to break through the interpreter performance ceiling.
  2. There's the macro wire format compatibility question that I raised in the Wasm macros PR. What if you build a macro with old Swift Syntax that new compiler doesn't understand? Or new Swift Syntax with older compiler? It could be just as applicable to native macro prebuilts, although I did try to address this at least for Wasm macros in that PR: the wire format does have versioning and there are fallback compatibility mechanisms that partially mitigate this. In the worst case scenario, a diagnostic message can be emitted at compile-time if wire format versions don't match.
3 Likes

WasmKit interpreter is ~3-5x slower an overage than equivalent native code, which roughly corresponds to the fetch-decode-execute native instruction cycles that interpreters need to "emulate" for every Wasm instruction.

Is this vs. release or debug native code? If release, it may still be a win since all third-party macros run in debug mode AFAIK. Additionally, since Wasm macros would be sandboxed at the interpreter level, is that the expansion process could be reused between invocations, which would be a huge win, especially for systems with expensive per-process costs (systems with security software).

On a related note, do we need Wasm macros to get proper expansion caching? That's really the biggest issue here, since the lack of caching makes the Swift module generation expand every macro a second time, which is hugely expensive.

Without something like Wasm there's no way for us to prevent non-determinism and side effects in macros consistently on all host platforms.

People can (and sometimes do) write weird macros that don't express inputs cleanly, but make sound caching impossible: invocations of Date(), environment variables, network and file system access, randomness etc. Wasm runtime allows us to intercept side effects in userland and either provide a deterministic default value and/or trigger a diagnostic message about non-determinism interfering with caching.

I’d really like to see the WASM approach picked back up. It seemed the most promising to me.

WASM would certainly make a lot of things easier and I'm glad Max is back into looking at that. It's proven to be a huge amount of work to set up the infrastructure to publish swift-syntax for all the combinations of host platform and architectures and compiler versions. Imagine only having to produce one. :).

That said, there's still a lot of work to get there.

2 Likes