[RFC] In-tree Swift Bindings

Hello everyone, I’ve recently been looking into enabling the use of Swift in MLIR and specifically CIRCT development. This is an RFC covering my ideas and approach.

Approach

We would like to begin taking incremental steps to develop in-tree Swift bindings that

  • Provide a modern programming language interface to MLIR constructs
  • Use Swift’s nascent C++ interoperability to provide a much lower friction interface to the C++ internals
    • The goal would be to rely as little as possible on the existing C interoperability layer, and more directly bridging type information that is currently discarded in the C bindings (i.e. all attributes are MlirAttribute) at lower cost.
  • Integrate as closely as possible with LLVM’s existing build system so projects like CIRCT could integrate Swift libraries in a way that is transparent to end users (with some details remaining to be ironed out like whether or not to statically link the Swift standard library)

Our incremental approach would looks something like this:

  1. Enable Swift targets to be compiled as part of the LLVM build flow (via add_llvm_library and friends) behind a flag (MLIR_ENABLE_BINDINGS_SWIFT).
  2. Enable C++ libraries to be imported using Swift’s -enable-experimental-cxx-interop flag
  3. Build out a small set of utilities to augment automatically-imported C++ APIs

As a future direction, we can also look at improving TableGen’s design capture to help auto-generate Swift-specific code.

Potential Issues

Unlike Python Bindings, which are interpreted, we hope to compile Swift code as part of the LLVM build flow, using CMake’s native support for Swift (enable_language(Swift)). This is incompatible with a few places in the LLVM build flow where “compiler” options and definitions are added which are only compatible with C/C++ compilers (i.e. -gsplit-dwarf and definitions with values (-DMLIR_CUDA_CONVERSIONS_ENABLED=0). The solution to this is to use CMake generator expressions ($<$<COMPILE_LANGUAGE:C,CXX>:-gsplit-dwarf>) which are a bit more verbose but specify what languages a particular compiler flag or definition applies to. FWIW, getting a Swift target to build in MLIR required me to fix this in about 4 places, so it doesn’t seem like a huge issue. Ideally, we would also set up CI to catch new such flags being added in the future.

Related Work

At SiFive, we have developed a set of bindings (MLIRSwift) which rely on the C API to present an integration experience similar to Python. MLIRSwift’s build infrastructure is designed for projects that “own” the top of the stack, this was a conscious decision to offer the most streamlined experience possible to developers (i.e. no need to worry about CMake), but it is also a limiting factor. We’ve seen a great deal of simplification and developer productivity benefits as we’ve used MLIRSwift to prototype things, and I hope to unlock similar results for in-tree CIRCT work

4 Likes

Excited to see this!

That’s cool! I am working on an Elixir one. Having Swift Binding in-tree should path the way for bindings in other languages!

1 Like

What is the scope of the bindings and what are the expected maintenance guarantees?

One of the big benefits of routing via the C API is for upstream developers to only care about that one interface most of the time. When somebody changes a C++ API, most of the time they just need to make sure the C API remains the same, e.g., by adding some extra boilerplaty code, and the other bindings will just keep working. With the direct C++ to Swift mapping, this doesn’t seem to be the case. Furthermore, in-tree Python bindings are written in C++ themselves, which gives upstream committers the possibility to debug and fix them. I don’t think we can expect the same for Swift bindings, which means they will be broken by upstream API updates, which seems to defy the purpose of putting them upstream.

Great questions!

The way I envision it, we can start very narrow in scope and grow it as community engagement grows.

Initial support can be limited to just building a Swift target and being able to import C++ dependencies from that Swift target. This will involve some changes in the CMake layer ($<$<COMPILE_LANGUAGE:C,CXX>:-gsplit-dwarf> and friends), and perhaps the addition of clang module maps to the core IR libraries. This presents a minimal maintenance burden, since the core CMake doesn’t seem to move all that much and module maps still work fine if a header file is omitted (similar to how new C++ API might not get C bindings until they are needed, but indeed better since novel C++ API that is supported by Swift’s C++ interop declared in an existing header file will automatically be bridged).

This level of integration will allow downstream project like CIRCT or a SiFive-specific repo to experiment with Swift targets, hopefully stabilizing a set of bridging helpers (String routines, conformance of core types to Swift protocols like Sequence, etc.). For reference, this layer of MLIRSwift has been pretty stable for the past year (and I expect such core elements to only become more stable as MLIR matures). Once we have something like this (effectively porting MLIRSwift’s core utilities to CMake/C++ interop) we can discuss upstreaming these utilities and the maintenance burden they would impose (which hopefully will be low because they would deal with the core, stabilized bits of MLIR).

Upstreaming core utilities and helpers should get us a to a point where downstream libraries can make integrated Swift targets that share a compatible foundation, which would be a big win in-and-of-itself. If any such libraries end up being particularly relevant to the broader MLIR community, we can at that point have a discussion about upstreaming such libraries and the maintenance burden that will entail (which would not be low, since now we would be adding the potential for C++ changes to require fixing Swift code). For the record, I don’t think we need to discuss that now as such a scenario is strictly hypothetical and there are concrete benefits from the two lower levels of integration mentioned above.

To summarize, I see the scope progressing like so:

  1. Enable/standardize how to declare Swift targets and import C++ dependencies into Swift in CMake (low maintenance burden)
  2. Upstream common bridging code, settle on how certain abstractions are bridged (conformance to Swift protocols, initializers, etc), (low maintenance burden if the core concepts being bridged have stabilized)
  3. (optional, would need to be heavily justified) Upstream useful Swift libraries for more complex concepts (high maintenance burden as C++ changes may require changes to Swift code, which has a broad impact on community)

I think that’s a pretty big “if”. Core is relatively stable these days but not because there’s been an effort to keep it stable for stability’s sake. I can think of a handful of seismic changes that could come in the future, not the mention the ones already in development (bitcode format, splitting intrinsic and discardable attributes :slight_smile:).

Correct. Though this is a “phase 2” concern. We can also be selective about what we bridge, and have a community discussion about how much maintenance burden each piece would add. I don’t foresee certain things like String utilities or Sequence conformance for blocks as being particularly unstable.

I share a lot of the same concerns as @ftynse . It still isn’t clear to me the level of maintenance this RFC intends to impose on the broader community, which is a serious concern I have about non-C++ language bindings in general. The intention to sidestep the C bindings also adds to the concern of having to maintain this special thing, especially given that it would require knowledge of how swift<->C++ magic (and swift in general) works. We need a consistent story for how language bindings are built, and the more complex/non-standard the glue gets the more I would posit that this should be off-by-default and only maintained by those interested (not that this would give a pass for huge or complex blobs of swift, we don’t want swift/etc code written for anything upstream).

– River

Can this build on the existing LLVM concepts for these things? E.g. the bazel build files in LLVM are not maintained by the LLVM project even though they are contained in it? Seems like a similar issue.

(caveat: I don’t know anything about Swift but I did create a lot of the Python bindings and co-developed the C API)

There was one other practical reason for developing the Python bindings against the C API vs the C++ API: the C++ used by Python binding layers (i.e. pybind/nanobind) is incompatible with “LLVM C++” – the former uses both RTTI and exceptions liberally and the latter does not/can not. While people do have an option to build LLVM with RTTI and exceptions enabled, in practice:

  • Doing so will “never” be supported for a default-enabled option.
  • LLVM (and MLIR) code is not exception safe and “never” will be.
  • While there are some limited ways to interop between these compilation modes, it is non-standard and a path paved with despair in practice.

Further, if not using one of the dominant, automagical C++/Python interop layers, then the other main choice is to code C based extensions – which then requires a C API anyway. Targeting a C API is the great equalizer on these things, and given that it has value on its own and leaves all of the doors open, that’s what we did.

I assume that Swift/C++ interop does not suffer from this same issue (i.e. does not require RTTI or C++ exceptions), but since I don’t know anything about it, thought I’d mention/document a story of warning and dread if you head that way :slight_smile:

From the responses to the initial point I think it would be great to focus on the initial most-limited form of Swift support that we can enable. This level is simply “I can have a Swift target (using CMake’s native Swift support) and depend on some core MLIR libraries (roughly as you would if you had a C++ target in a downstream repo)”. This creates significant benefit (downstream project can build Swift targets if the tradeoffs make sense for them), with little downside (a line or two of special casing Swift targets in LLVM’s build infrastructure, and a slightly more verbose-but arguably more correct-phrasing for compiler options and definitions). To be clear, I don’t see how this level of support has a significant maintenance burden.
Down the line, we can have a subsequent RFC to discuss the pros/cons of deeper integration, but that discussion will be easier to have with things some concrete downstream examples and experience under our belt.

I think this is a bit different. Google cares a lot about things being Bazel-y, but I think for our use case it is actually very advantageous to be directly integrated into the CMake build flow. The only files that would need to be maintained (outside of making sure new compiler flags don’t break the Swift build) would be the module maps and those are remarkably straightforward (basically just a list of headers) and the omission of a header file will not break downstream targets. So in effect, it would be up to downstream project with Swift targets to maintain the module maps.

One goal here would be to be able to write a CIRCT transform in Swift, have it compile into firtool (defined using add_llvm_executable in CIRCT) and have the fact that we use Swift be transparent to the end user.

This was also the reason MLIRSwift was based on the C bindings, and it has mostly served us well so far. Integrating Swift in the MLIR build flow would have slightly different goals, however. Basically it would be equivalent to having a C++ target that depends on MLIR targets (like all current targets in CIRCT, for instance), but allow that to be developed in Swift instead of C++ (for a variety of reasons). A Swift target defined this way this would place the exact same maintenance burden on core MLIR that a C++ target would, namely none at all (MLIR breaks CIRCT all the time and that has worked OK so far!).

I think what Chris was referring to was more the community expectations vs the specifics. There is no expectation of community support for the Bazel build system, except by the people who care about it. This even extends to presubmit checks: only those of us (I unfortunately, by virtue of my employer, have to care about this part sometimes) who subscribe to a special group even see the bazel build status on our phab reviews.

We could talk about how “separatism” is achieved in CMake, but the simplest thing is just a CMake option that, when enabled, does an add_subdirectory on an entire sub-tree that is self contained for the feature (there may be some other minor global setup, but I would think it is all conditioned on the same flag).

I don’t really have (much of) an opinion on whether this should or shouldn’t be done – just noting the approach. In general, I am supportive of the CMake build working for downstreams and not imposing arbitrary restrictions on what they can do. It sounds like you uncovered a few of those to start with, so +1 to fixing that. It also sounds like you would like to have a little place in the tree so that if Swift support is enabled, downstreams can have some common boilerplate and such provided for them. I don’t have a problem with that, personally, and think it is a nice thing to enable so long as it doesn’t cost anyone outside of that part of the ecosystem more than the kilobytes of files that will be there.

It probably won’t be the only language that needs some “interop plumbing”, so maybe having a directory tree that allows for others to exist too (i.e. mlir/interop/swift or equiv). The one public header file we have on the Python side of this nature (mlir-c/Bindings/Python/Interop.h) is the one thing that an outside party looking to interop with the in-tree Python/C APIs needs. I don’t love the location, and it is a little different in scope, if not purpose, to what you are looking for, I think.

I think that the above is quite a bit less than committing to “full bindings” or things of that nature, and it likely represents the absolutely least we can do to be a good citizen in a multi-language world.

My 2 cents.

Agreed with Chris, River and Stella here. The important consideration for me here is that we cannot expect MLIR developers to be polyglots and in particular can’t block submission on needing to chase down all languages binding users (and this includes Python, even though I use it actively :slight_smile: ). This would have to be optional (disable or enable with cmake option and then be completely disabled; I like having it completely skip a directory), users of it would need to be able to handle it being broken (e.g., it is not a blocker for submit) and users/community around it updates it. At least that feels like good starting point and similar to Bazel, which is good example: only few care (O(100)?) and fewer update, so it’s broken “often” (well they are written to be broken less often by using some non-optimal bazel forms) and users have to be OK with being able to build only at say one specific commit every 7 working days. Now in reality one can build it much more often, but there aren’t any guarantee around it. If firtool depends on this and we won’t require any developers to update Swift bindings, then firtool needs to be able to be broken until you or someone fixes Swift build.

(I think this, Elixir, Rust and even Haskell bindings are very exciting and would love to see them pushed and have them push the core infra too! I just would hate to get to a point where one has to understand all of them or get updates/help from all these groups before landing an MLIR change).

It sounds to me like we are all in agreement? My current path forward is:

  1. Fixes the fact that LLVM’s core CMake abstractions are implicitly tightly coupled with the C/C++ compiler by making such couplings explicit (via CMake’s elegant $<$<COMPILE_LANGUAGE:C,CXX>:-gsplit-dwarf> syntax).
  2. Create module maps for core IR libraries
  3. Build some stuff downstream using Swift, fix any breakages in MLIR that pop up
  4. (optionally) revisit upstreaming more Swift infrastructure

I don’t have any idea what this means (without using my imagination) :slight_smile: I don’t really need to, but if possible, maybe bias towards a small educational patch to get the ball rolling? I imagine we’re going to need to bike-shed location/name/etc, so I’d bias the first attempt to something easy to reset.

Sorry for the confusion, Stella is correct:

I think what Chris was referring to was more the community expectations vs the specifics. There is no expectation of community support for the Bazel build system, except by the people who care about it.

What I’m getting at is that MLIR (by it’s very nature of being useful for many things) will have lots of people want to use it in many ways. This leads to a more diverse community than LLVM had, and it therefore needs to be prepared to deal with things in tree that are not maintained to the same standards are the core IR infra - for example, refactoring and changing the core shouldn’t be blocked by ocaml bindings or some weird experimental dialect.

-Chris

One tangential note I want to make is that introduction of Swift dependencies in CIRCT would likely have a impact on an earlier discussion: RFC: Graduate CIRCT to monorepo?

This would be a move away from LLVM’s conventions and approaches.

Thanks for bringing this up. From the discussions I’ve participated in around this issue is that there will likely be some parts of CIRCT that will need to live outside the monorepo (or, at least, in some experimental area of the monorepo). This is both for maintenance reasons and if we end up having proprietary passes/dialects. We can decide what to do when a more clear plan develops around this (and if we have Swift libraries in CIRCT at the time).

One other tangential concern that I have here is that Swift’s C++ interoperability is far from complete, and I worry if there is a reasonable expectation of stability in the implementation.

I generally think adding bindings for is fine under the right support policy, but adding bindings that rely on in-progress language features of the new language makes me a bit worried.

Two really specific questions:
(1) What version of Swift is required to build this?
(2) Is the support available in the publicly available pre-built toolchain?

I get extremely concerned if we’re adding dependencies on pre-release versions of tools and functionality that may not be available in official distributions.

So if we are focused on just “You can build a Swift target”, then this has no requirements on experimental language features and will work with any version of Swift. Potentially if we add “you can import MLIR into Swift” as something we intend to maintain (requiring only that MLIR provide valid module maps) this can be achieved using the incoming (5.7) toolchain. For more active adoption of C++ interop, nightly Swift toolchains must be used (which are publicly available pre-built).

Part of the impetus to do this now is that C++ interop is actively being developed, so having an understanding of how it currently works in terms of MLIR can be beneficial to inform how interop gets built. To be perfectly clear, such experimentation will only be enabled by work in the monorepo, and will actually happen in downstream repos, creating no maintenance burden for core MLIR.